commit 1eea4b9eaa5fbe527bd514daf9757159fa8c6984 Author: TermiNexus Date: Sat Aug 22 04:45:37 2026 +0800 增加喷射效果 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..524fb1b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +# Automatically build the project and run any configured tests for every push +# and submitted pull request. This can help catch issues that only occur on +# certain platforms or Java versions, and provides a first line of defence +# against bad commits. + +name: build +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: checkout repository + uses: actions/checkout@v6 + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: setup jdk + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'microsoft' + - name: make gradle wrapper executable + run: chmod +x ./gradlew + - name: build + run: ./gradlew build + - name: capture build artifacts + uses: actions/upload-artifact@v7 + with: + name: Artifacts + path: build/libs/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c476faf --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# gradle + +.gradle/ +build/ +out/ +classes/ + +# eclipse + +*.launch + +# idea + +.idea/ +*.iml +*.ipr +*.iws + +# vscode + +.settings/ +.vscode/ +bin/ +.classpath +.project + +# macos + +*.DS_Store + +# fabric + +run/ + +# java + +hs_err_*.log +replay_*.log +*.hprof +*.jfr diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..674c7c8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "blockbench": { + "command": "node", + "args": ["D:\\tools\\blockbench-mcp\\dist\\index.js"], + "env": { + "BLOCKBENCH_MCP_PORT": "8787" + } + } + } +} diff --git a/DeployPack.ps1 b/DeployPack.ps1 new file mode 100644 index 0000000..3a786e9 --- /dev/null +++ b/DeployPack.ps1 @@ -0,0 +1,46 @@ +# DeployPack.ps1 - Deploy built mod jar to client & server mods, plus the v1.4.1 resource pack to server +$ErrorActionPreference = 'Stop' + +$Jar = 'D:\Users\TermiNexus\Desktop\NeedsOfNature-1.20.1-port-main\build\libs\needsofnature-1.4.4.jar' +$ClientMods = 'D:\Users\TermiNexus\Desktop\HMCL\OLD\.minecraft\versions\1.21.11-Fabric-Fuck\mods' +$ServerMods = 'D:\MCServer\MCN\mods' + +# --- Resource pack (original feature) --- +$PackSrc = 'D:\Users\TermiNexus\Desktop\HMCL\OLD\.minecraft\versions\1.21.11-Fabric-Fuck\resourcepacks\needs_of_nature_default_packv1.4.1' +$PackDst = 'D:\MCServer\MCN\world\datapacks\needs_of_nature_default_packv1.4.1' + +if (-not (Test-Path -LiteralPath $Jar)) { throw "Built jar not found: $Jar" } + +# 1. Client mods: remove previous deploy, back up original, copy new jar +$Old = "$ClientMods\needsofnature-1.0.0.jar" +if (Test-Path -LiteralPath $Old) { + Remove-Item -LiteralPath $Old -Force + Write-Output "Removed old client deploy: $Old" +} +$OrigClient = "$ClientMods\needsofnature-1.4.4..jar" +if (Test-Path -LiteralPath $OrigClient) { + Move-Item -LiteralPath $OrigClient -Destination "$OrigClient.bak" -Force + Write-Output "Backed up client mod: $OrigClient -> .bak" +} +Copy-Item -LiteralPath $Jar -Destination "$ClientMods\needsofnature-1.4.4.jar" -Force +Write-Output "Client mod deployed: $ClientMods\needsofnature-1.4.4.jar" + +# 2. Server mods: back up original, copy new jar (server may be running -> file locked) +$OrigServer = "$ServerMods\needsofnature-1.4.4..jar" +try { + if (Test-Path -LiteralPath $OrigServer) { + Move-Item -LiteralPath $OrigServer -Destination "$OrigServer.bak" -Force + Write-Output "Backed up server mod: $OrigServer -> .bak" + } + Copy-Item -LiteralPath $Jar -Destination "$ServerMods\needsofnature-1.4.4.jar" -Force + Write-Output "Server mod deployed: $ServerMods\needsofnature-1.4.4.jar" +} catch { + Write-Output "WARNING: Server mod deploy failed - file is locked. Stop the server first, then re-run this script." + throw $_ +} + +# 3. Resource pack to server datapacks (original feature) +if (-not (Test-Path -LiteralPath $PackSrc)) { throw "Resource pack not found: $PackSrc" } +if (Test-Path -LiteralPath $PackDst) { Remove-Item -LiteralPath $PackDst -Recurse -Force } +Copy-Item -LiteralPath $PackSrc -Destination $PackDst -Recurse -Force +Write-Output "Resource pack deployed: $PackSrc -> $PackDst" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1625c17 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..edfaa05 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# NeedsOfNature + +## Setup + +For setup instructions, please see the [Fabric Documentation page](https://docs.fabricmc.net/develop/getting-started/creating-a-project#setting-up) related to the IDE that you are using. + +## License + +This template is available under the CC0 license. Feel free to learn from it and incorporate it in your own projects. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..35d1cd4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'maven-publish' +} + +repositories { + // Add repositories to retrieve artifacts from in here. + // You should only use this when depending on other mods because + // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. + // See https://docs.gradle.org/current/userguide/declaring_repositories.html + // for more information about repositories. +} + +dependencies { + // To change the versions see the gradle.properties file + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + + // Fabric API. This is technically optional, but you probably want it anyway. + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + + // 原版 mod 源码已在本工程内(src),这里只挂外部依赖(本地 mods 文件夹) + def modsDir = "D:/Users/TermiNexus/Desktop/HMCL/OLD/.minecraft/versions/1.21.11-Fabric-Fuck/mods" + modImplementation files("${modsDir}/geckolib-fabric-1.21.11-5.4.5.jar") + modImplementation files("${modsDir}/trinkets-3.11.0-beta.2+1.21.11.jar") + modImplementation files("${modsDir}/cardinal-components-api-7.3.2.jar") + modImplementation files("${modsDir}/cardinal-components-base-7.3.2.jar") + modImplementation files("${modsDir}/placeholder-api-2.8.2+1.21.10.jar") + modImplementation files("${modsDir}/modmenu-17.0.1-beta.1.jar") + modImplementation files("${modsDir}/needsofnature-intiface-libs-generated.jar") + // Jade(snownee.jade.api)—— 编译需要 + def jadeJar = "${modsDir}/Jade-1.21.11.jar" + if (!new File(jadeJar).exists()) { + throw new GradleException("缺少 Jade mod jar,请放到: ${jadeJar}") + } + modImplementation files(jadeJar) +} + +processResources { + def version = project.version + inputs.property "version", version + + filesMatching("fabric.mod.json") { + expand "version": version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 +} + +java { + // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task + // if it is present. + // If you remove this line, sources will not be generated. + withSourcesJar() + + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + def projectName = project.name + inputs.property "projectName", projectName + + from("LICENSE") { + rename { "${it}_$projectName"} + } +} + +// configure the maven publication +publishing { + publications { + create("mavenJava", MavenPublication) { + from components.java + } + } + + // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. + repositories { + // Add repositories to publish to here. + // Notice: This block does NOT have the same function as the block in the top level. + // The repositories here will be used for publishing your artifact, not for + // retrieving dependencies. + } +} diff --git a/find.py b/find.py new file mode 100644 index 0000000..1af572b --- /dev/null +++ b/find.py @@ -0,0 +1,163 @@ +""" +REPL 模式文件内容搜索工具 +支持正则或纯文本搜索,默认搜索当前目录 +""" +import os +import re +import sys + +# 硬编码排除目录 +EXCLUDE_DIRS = { + '.git', '.vs', 'node_modules', '__pycache__', '.idea', + 'miniprogram_npm', '_backup_pre_redesign', +} + +# 排除的文件扩展名 +EXCLUDE_EXTS = { + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.bmp', + '.mp3', '.mp4', '.wav', '.avi', + '.zip', '.rar', '.7z', '.tar', '.gz', + '.exe', '.dll', '.so', '.dylib', + '.pyc', '.pyo', '.class', '.o', '.obj', + '.db', '.sqlite', '.vsidx', +} + +# 默认搜索根目录 +DEFAULT_ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def should_skip(dirpath): + """判断目录是否应跳过""" + parts = dirpath.replace('\\', '/').split('/') + return any(p in EXCLUDE_DIRS for p in parts) + + +def search(pattern, root=DEFAULT_ROOT, use_regex=True, max_results=50): + """搜索文件内容""" + if use_regex: + try: + compiled = re.compile(pattern, re.IGNORECASE) + except re.error as e: + print(f' 正则语法错误: {e}') + return + + count = 0 + for dirpath, dirnames, filenames in os.walk(root): + # 就地修改 dirnames 以跳过排除目录 + dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] + + for fname in filenames: + ext = os.path.splitext(fname)[1].lower() + if ext in EXCLUDE_EXTS: + continue + + fpath = os.path.join(dirpath, fname) + relpath = os.path.relpath(fpath, root) + try: + with open(fpath, 'r', encoding='utf-8', errors='ignore') as f: + for lineno, line in enumerate(f, 1): + line_stripped = line.rstrip('\n\r') + if use_regex: + match = compiled.search(line_stripped) + if match: + highlight = line_stripped[:match.start()] + \ + f'>>> {match.group()} <<<' + \ + line_stripped[match.end():] + print(f' {relpath}:{lineno} {highlight}') + count += 1 + else: + if pattern.lower() in line_stripped.lower(): + print(f' {relpath}:{lineno} {line_stripped}') + count += 1 + + if count >= max_results: + print(f'\n ... 已达上限 {max_results} 条,停止') + return + except (PermissionError, OSError): + continue + + if count == 0: + print(' 未找到匹配内容') + else: + print(f'\n 共 {count} 条匹配') + + +def main(): + print('=== 文件内容搜索工具 ===') + print(f'搜索根目录: {DEFAULT_ROOT}') + print(f'排除目录: {EXCLUDE_DIRS}') + print() + print('命令:') + print(' /regex 正则搜索') + print(' /text 纯文本搜索') + print(' /max 设置结果上限(默认50)') + print(' /dir 切换搜索目录') + print(' /help 显示帮助') + print(' /quit 退出') + print() + print('直接输入内容默认使用【纯文本】搜索,需要正则请加 /regex 前缀') + print() + + root = DEFAULT_ROOT + use_regex = False # 修改1:默认纯文本 + max_results = 50 + + while True: + try: + raw = input('find> ').strip() + except (EOFError, KeyboardInterrupt): + print('\n再见') + break + + if not raw: + continue + + if raw == '/quit': + print('再见') + break + + if raw == '/help': + print(' /regex 正则搜索') + print(' /text 纯文本搜索') + print(' /max 设置结果上限') + print(' /dir 切换搜索目录') + print(' /help 显示帮助') + print(' /quit 退出') + print(' 直接输入内容默认纯文本搜索') + continue + + if raw.startswith('/regex '): + pattern = raw[7:].strip() + if pattern: + search(pattern, root, use_regex=True, max_results=max_results) + continue + + if raw.startswith('/text '): + pattern = raw[6:].strip() + if pattern: + search(pattern, root, use_regex=False, max_results=max_results) + continue + + if raw.startswith('/max '): + try: + max_results = int(raw[5:].strip()) + print(f' 结果上限设为 {max_results}') + except ValueError: + print(' 请输入数字') + continue + + if raw.startswith('/dir '): + new_dir = raw[5:].strip() + if os.path.isdir(new_dir): + root = os.path.abspath(new_dir) + print(f' 搜索目录切换为: {root}') + else: + print(f' 目录不存在: {new_dir}') + continue + + # 修改2:直接输入走纯文本搜索 + search(raw, root, use_regex=False, max_results=max_results) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..625eb84 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +# Done to increase the memory available to gradle. +org.gradle.jvmargs=-Xmx1G +org.gradle.parallel=true + +# IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 +org.gradle.configuration-cache=false + +# Fabric Properties +# check these on https://fabricmc.net/develop +minecraft_version=1.21.11 +yarn_mappings=1.21.11+build.6 +loader_version=0.19.3 +loom_version=1.17-SNAPSHOT + +# Mod Properties +version=1.4.4 +group=com.nonid + +# Dependencies +fabric_api_version=0.141.6+1.21.11 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..084d8bb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=file:///D:/%E8%BF%85%E9%9B%B7%E4%B8%8B%E8%BD%BD/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b749c75 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +// Should match your modid +rootProject.name = 'needsofnature' diff --git a/src/main/java/com/nonid/DebugStaffItem.java b/src/main/java/com/nonid/DebugStaffItem.java new file mode 100644 index 0000000..f83d152 --- /dev/null +++ b/src/main/java/com/nonid/DebugStaffItem.java @@ -0,0 +1,192 @@ +package com.nonid; + +import com.nonid.data.NonEntityProfiles; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Item.Settings; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Formatting; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; + +public class DebugStaffItem extends Item { + public DebugStaffItem(Settings settings) { + super(settings); + } + + public ActionResult useOnEntity(ItemStack stack, PlayerEntity user, LivingEntity entity, Hand hand) { + if (entity instanceof EnergyHolder holder) { + if (user.getEntityWorld().isClient()) { + return ActionResult.SUCCESS; + } else { + ServerPlayerEntity serverPlayer = (ServerPlayerEntity)user; + if (user.isSneaking()) { + int current = holder.getEnergy(); + int max = holder.getMaxEnergy(); + int next = Math.min(max, current + 25); + holder.setEnergy(next); + return ActionResult.SUCCESS; + } else { + this.sendDebugReport(serverPlayer, entity, holder); + return ActionResult.SUCCESS; + } + } + } else { + return ActionResult.PASS; + } + } + + private void sendDebugReport(ServerPlayerEntity player, LivingEntity entity, EnergyHolder holder) { + Identifier entityId = Registries.ENTITY_TYPE.getId(entity.getType()); + player.sendMessage(Text.literal("[NoN 调试] " + entity.getDisplayName().getString() + " (" + entityId + ")").formatted(Formatting.GREEN), false); + player.sendMessage(Text.literal("UUID: " + entity.getUuid()).formatted(Formatting.GRAY), false); + player.sendMessage(Text.literal("性别: " + this.resolveGender(entity)).formatted(Formatting.GRAY), false); + player.sendMessage( + Text.literal("能量: " + holder.getEnergy() + "/" + holder.getMaxEnergy() + " | 已初始化: " + holder.isEnergyInitialized()) + .formatted(Formatting.GRAY), + false + ); + player.sendMessage( + Text.literal( + "能量增益: 倍率 " + + this.format(holder.getEnergyGainMultiplier()) + + " | 漂移 " + + this.format(holder.getEnergyGainDrift()) + + " | 实体 " + + this.format(NonEntityProfiles.resolveEnergyGainMultiplier(entity)) + ) + .formatted(Formatting.GRAY), + false + ); + if (entity.getEntityWorld() instanceof ServerWorld world) { + boolean activeOrPending = NeedsOfNature.isActorPendingOrActive(world, entity.getUuid()); + player.sendMessage(Text.literal("动画: 活跃/待定 " + activeOrPending).formatted(Formatting.GRAY), false); + this.sendAuraReport(player, world, entity, holder); + this.sendCooldownReport(player, world, entity, holder); + } + + if (entity instanceof LiquidHolder liquidHolder) { + Identifier liquidType = liquidHolder.getLiquidEntityTypeId(); + String liquidInfo = liquidHolder.getLiquidStored() + + "/" + + liquidHolder.getLiquidCapacity() + + "ml | composition " + + liquidHolder.getLiquidComposition() + + (liquidType == null ? "" : " | 实体 " + liquidType); + if (entity instanceof ServerPlayerEntity) { + liquidInfo = liquidInfo + " | 槽类型 " + NonGenderSystem.getLiquidTankType(entity); + } + + player.sendMessage(Text.literal("液体: " + liquidInfo).formatted(Formatting.GRAY), false); + } + + if (entity instanceof PregnancyHolder pregnancyHolder) { + Identifier pregnancyType = pregnancyHolder.getPregnantEntityTypeId(); + String pregnancyInfo = pregnancyType == null + ? "none" + : pregnancyType + " | 结束tick " + pregnancyHolder.getPregnancyEndTick() + " | 待孵化 " + pregnancyHolder.isPregnancyPendingHatch(); + player.sendMessage(Text.literal("孕期: " + pregnancyInfo).formatted(Formatting.GRAY), false); + } + } + + private void sendAuraReport(ServerPlayerEntity player, ServerWorld world, LivingEntity entity, EnergyHolder holder) { + if (entity instanceof MobEntity mob) { + NeedsOfNature.PlayerEnergyAuraDebug aura = NeedsOfNature.getPlayerEnergyAuraDebug(world, mob, holder); + if (aura == null) { + player.sendMessage(Text.literal("光环: 无 | 携带 " + this.format(holder.getEnergyAuraCarry())).formatted(Formatting.GRAY), false); + } else { + String sourceName = aura.player().getName().getString(); + player.sendMessage( + Text.literal( + "光环: " + + this.format(aura.totalMultiplier()) + + "x 来自 " + + sourceName + + " | 能量 " + + aura.playerEnergy() + + " | 破坏 " + + aura.destroyedSkinStage() + + " | 污渍 " + + aura.messTotal() + + " | 半径 " + + this.format(aura.radius()) + + " | 携带 " + + this.format(holder.getEnergyAuraCarry()) + ) + .formatted(Formatting.GRAY), + false + ); + } + } else { + player.sendMessage(Text.literal("光环: 无").formatted(Formatting.GRAY), false); + } + } + + private void sendCooldownReport(ServerPlayerEntity player, ServerWorld world, LivingEntity entity, EnergyHolder holder) { + List cooldowns = new ArrayList<>(); + String attackFailsafe = NeedsOfNature.getMobAttackFailsafeDebug(world, entity.getUuid()); + if (attackFailsafe != null && !attackFailsafe.isBlank()) { + cooldowns.add(attackFailsafe); + } + + cooldowns.addAll(holder.getActiveCooldownDebugLines(world)); + if (cooldowns.isEmpty()) { + player.sendMessage(Text.literal("冷却: 无").formatted(Formatting.GRAY), false); + } else { + player.sendMessage(Text.literal("冷却:").formatted(Formatting.GRAY), false); + + for (String line : cooldowns) { + player.sendMessage(Text.literal("- " + line).formatted(Formatting.DARK_GRAY), false); + } + } + } + + private String resolveGender(LivingEntity entity) { + if (entity instanceof GenderHolder genderHolder) { + return this.formatGenderMask(genderHolder.getGenderMask()); + } else { + boolean hasMale = entity.getCommandTags().contains("gender.male"); + boolean hasFemale = entity.getCommandTags().contains("gender.female"); + int mask = 0; + if (hasMale) { + mask |= 1; + } + + if (hasFemale) { + mask |= 2; + } + + return this.formatGenderMask(mask); + } + } + + private String formatGenderMask(int mask) { + boolean male = (mask & 1) != 0; + boolean female = (mask & 2) != 0; + if (male && female) { + return "male & female"; + } else if (male) { + return "male"; + } else { + return female ? "female" : "none"; + } + } + + private String format(float value) { + return String.format(Locale.ROOT, "%.2f", value); + } + + private String format(double value) { + return String.format(Locale.ROOT, "%.2f", value); + } +} diff --git a/src/main/java/com/nonid/DestroyedSkinHolder.java b/src/main/java/com/nonid/DestroyedSkinHolder.java new file mode 100644 index 0000000..bd86440 --- /dev/null +++ b/src/main/java/com/nonid/DestroyedSkinHolder.java @@ -0,0 +1,11 @@ +package com.nonid; + +public interface DestroyedSkinHolder { + int getDestroyedSkinStage(); + + void setDestroyedSkinStage(int var1); + + int getDestroyedSkinDamage(); + + void setDestroyedSkinDamage(int var1); +} diff --git a/src/main/java/com/nonid/EnergyHolder.java b/src/main/java/com/nonid/EnergyHolder.java new file mode 100644 index 0000000..8271d28 --- /dev/null +++ b/src/main/java/com/nonid/EnergyHolder.java @@ -0,0 +1,62 @@ +package com.nonid; + +import java.util.List; +import java.util.UUID; +import net.minecraft.entity.LivingEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; + +public interface EnergyHolder { + int getEnergy(); + + int getMaxEnergy(); + + void setEnergy(int var1); + + default float getEnergyGainMultiplier() { + return 1.0F; + } + + default void setEnergyGainMultiplier(float value) { + } + + default float getEnergyGainDrift() { + return 1.0F; + } + + default void setEnergyGainDrift(float value) { + } + + default boolean isEnergyInitialized() { + return false; + } + + default void markEnergyInitialized() { + } + + default void ensureEnergyInitialized(ServerWorld world) { + } + + default void onNonAnimationStarted() { + } + + default void onNonAttackEscaped(LivingEntity player, int retryDelayTicks) { + } + + default void requestAttackOnPlayer(ServerWorld world, ServerPlayerEntity player) { + } + + default void applyPartnerCooldown(UUID partnerId, long nowTick, int cooldownTicks) { + } + + default List getActiveCooldownDebugLines(ServerWorld world) { + return List.of(); + } + + default double getEnergyAuraCarry() { + return 0.0; + } + + default void setEnergyAuraCarry(double value) { + } +} diff --git a/src/main/java/com/nonid/GenderHolder.java b/src/main/java/com/nonid/GenderHolder.java new file mode 100644 index 0000000..bb392f1 --- /dev/null +++ b/src/main/java/com/nonid/GenderHolder.java @@ -0,0 +1,16 @@ +package com.nonid; + +public interface GenderHolder { + int GENDER_NONE = 0; + int GENDER_MALE = 1; + int GENDER_FEMALE = 2; + int GENDER_BOTH = 3; + + int getGenderMask(); + + void setGenderMask(int var1); + + default boolean hasGender(int mask) { + return (this.getGenderMask() & mask) != 0; + } +} diff --git a/src/main/java/com/nonid/LiquidDonorHistory.java b/src/main/java/com/nonid/LiquidDonorHistory.java new file mode 100644 index 0000000..831e20f --- /dev/null +++ b/src/main/java/com/nonid/LiquidDonorHistory.java @@ -0,0 +1,110 @@ +package com.nonid; + +import com.google.gson.Gson; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class LiquidDonorHistory { + public static final int MAX_ENTRIES = 3; + private static final Gson GSON = new Gson(); + + private LiquidDonorHistory() { + } + + public static List append( + Collection current, Identifier entityTypeId, @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + ArrayList entries = new ArrayList<>(normalize(current)); + entries.add(new LiquidDonorHistory.Entry(entityTypeId, variantData)); + return keepNewest(entries); + } + + public static List normalize(@Nullable Collection entries) { + if (entries != null && !entries.isEmpty()) { + ArrayList normalized = new ArrayList<>(); + + for (LiquidDonorHistory.Entry entry : entries) { + if (entry != null && entry.entityTypeId() != null) { + normalized.add(new LiquidDonorHistory.Entry(entry.entityTypeId(), entry.variantData())); + } + } + + return keepNewest(normalized); + } else { + return List.of(); + } + } + + @Nullable + public static String encode(Collection entries) { + List normalized = normalize(entries); + if (normalized.isEmpty()) { + return null; + } + + LiquidDonorHistory.StoredEntry[] stored = normalized.stream() + .map(entry -> new LiquidDonorHistory.StoredEntry(entry.entityTypeId().toString(), entry.variantData())) + .toArray(LiquidDonorHistory.StoredEntry[]::new); + return GSON.toJson(stored); + } + + public static List decode(@Nullable String raw) { + if (raw != null && !raw.isBlank()) { + try { + LiquidDonorHistory.StoredEntry[] stored = (LiquidDonorHistory.StoredEntry[])GSON.fromJson(raw, LiquidDonorHistory.StoredEntry[].class); + if (stored != null && stored.length != 0) { + ArrayList decoded = new ArrayList<>(); + + for (LiquidDonorHistory.StoredEntry entry : stored) { + if (entry != null && entry.entityTypeId() != null) { + Identifier entityTypeId = Identifier.tryParse(entry.entityTypeId()); + if (entityTypeId != null) { + decoded.add(new LiquidDonorHistory.Entry(entityTypeId, entry.variantData())); + } + } + } + + return keepNewest(decoded); + } else { + return List.of(); + } + } catch (RuntimeException ignored) { + return List.of(); + } + } else { + return List.of(); + } + } + + private static List keepNewest(List entries) { + if (entries != null && !entries.isEmpty()) { + int fromIndex = Math.max(0, entries.size() - 3); + return List.copyOf(entries.subList(fromIndex, entries.size())); + } else { + return List.of(); + } + } + + @Nullable + private static NeedsOfNature.PregnancyVariantData normalizeVariantData(@Nullable NeedsOfNature.PregnancyVariantData variantData) { + return variantData != null && !variantData.isEmpty() + ? new NeedsOfNature.PregnancyVariantData(variantData.components(), variantData.horseMarkingIndex()) + : null; + } + + public record Entry(Identifier entityTypeId, @Nullable NeedsOfNature.PregnancyVariantData variantData) { + public Entry { + if (entityTypeId == null) { + throw new IllegalArgumentException("entityTypeId cannot be null"); + } + + variantData = LiquidDonorHistory.normalizeVariantData(variantData); + } + } + + private record StoredEntry(String entityTypeId, @Nullable NeedsOfNature.PregnancyVariantData variantData) { + } +} diff --git a/src/main/java/com/nonid/LiquidHolder.java b/src/main/java/com/nonid/LiquidHolder.java new file mode 100644 index 0000000..b0941a5 --- /dev/null +++ b/src/main/java/com/nonid/LiquidHolder.java @@ -0,0 +1,55 @@ +package com.nonid; + +import java.util.List; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public interface LiquidHolder { + int getLiquidStored(); + + int getLiquidCapacity(); + + void setLiquidStored(int var1); + + LiquidHolder.LiquidComposition getLiquidComposition(); + + @Nullable + Identifier getLiquidEntityTypeId(); + + @Nullable + NeedsOfNature.PregnancyVariantData getLiquidDonorVariantData(); + + void setLiquidCompositionMixed(); + + void setLiquidCompositionEntity(Identifier var1); + + void setLiquidDonorVariantData(@Nullable NeedsOfNature.PregnancyVariantData var1); + + List getRecentLiquidDonors(); + + void setRecentLiquidDonors(List var1); + + default void recordRecentLiquidDonor(Identifier entityTypeId, @Nullable NeedsOfNature.PregnancyVariantData variantData) { + if (entityTypeId != null) { + this.setRecentLiquidDonors(LiquidDonorHistory.append(this.getRecentLiquidDonors(), entityTypeId, variantData)); + } + } + + default void clearRecentLiquidDonors() { + this.setRecentLiquidDonors(List.of()); + } + + void clearLiquidComposition(); + + default void addLiquidStored(int amount) { + if (amount != 0) { + this.setLiquidStored(this.getLiquidStored() + amount); + } + } + + enum LiquidComposition { + EMPTY, + MIXED, + ENTITY; + } +} diff --git a/src/main/java/com/nonid/MessHolder.java b/src/main/java/com/nonid/MessHolder.java new file mode 100644 index 0000000..73db3e3 --- /dev/null +++ b/src/main/java/com/nonid/MessHolder.java @@ -0,0 +1,15 @@ +package com.nonid; + +public interface MessHolder { + int getVMess(); + + void setVMess(int var1); + + int getAMess(); + + void setAMess(int var1); + + int getMMess(); + + void setMMess(int var1); +} diff --git a/src/main/java/com/nonid/NeedsOfNature.java b/src/main/java/com/nonid/NeedsOfNature.java new file mode 100644 index 0000000..2d8eb97 --- /dev/null +++ b/src/main/java/com/nonid/NeedsOfNature.java @@ -0,0 +1,6388 @@ +package com.nonid; + +import com.google.gson.Gson; +import com.nonid.api.NonEvents; +import com.nonid.api.animation.NonActorEvents; +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.client.NonLiquidColors; +import com.nonid.data.NonAccessoryDefinitions; +import com.nonid.data.NonAccessoryItemRegistry; +import com.nonid.data.NonEntityProfiles; +import com.nonid.data.NonLiquidGainOverrides; +import com.nonid.data.NonLiquidRoles; +import com.nonid.data.NonManualPeakDefinitions; +import com.nonid.data.NonPeakStages; +import com.nonid.effect.NonStatusEffects; +import com.nonid.entity.HorseLiquidCollectorEntity; +import com.nonid.entity.PregnancyEggEntity; +import com.nonid.integration.NonWildfireGenderConfigCompat; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.api.AnimationLifecycleEvents; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationCueIndex; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.server.AnimationStartContext; +import com.nonid.internal.animation.server.ServerAnimationController; +import com.nonid.internal.animation.util.AnimationIds; +import com.nonid.internal.animation.util.StageTimeWarp; +import com.nonid.item.EnergyGainAdjustItem; +import com.nonid.item.EnergyStabilizerItem; +import com.nonid.item.HorseLiquidCollectorItem; +import com.nonid.network.AccessoryShedStateS2CPayload; +import com.nonid.network.AnimationCatalogChunkS2CPayload; +import com.nonid.network.AnimationCatalogData; +import com.nonid.network.AttackStateS2CPayload; +import com.nonid.network.DestroyedSkinMaskSyncS2CPayload; +import com.nonid.network.DestroyedSkinMaskUploadC2SPayload; +import com.nonid.network.DestroyedSkinParticlesS2CPayload; +import com.nonid.network.DestroyedSkinStateS2CPayload; +import com.nonid.network.DestroyedSkinSyncS2CPayload; +import com.nonid.network.DestroyedSkinUploadC2SPayload; +import com.nonid.network.GenderSelectionPromptS2CPayload; +import com.nonid.network.HostConfigSyncData; +import com.nonid.network.HostConfigSyncS2CPayload; +import com.nonid.network.LiquidStateS2CPayload; +import com.nonid.network.ManualDamageDestroyedSkinC2SPayload; +import com.nonid.network.MessStateS2CPayload; +import com.nonid.network.PlayerAnimationCancelC2SPayload; +import com.nonid.network.PlayerAnimationChoiceC2SPayload; +import com.nonid.network.PlayerAnimationConsentC2SPayload; +import com.nonid.network.PlayerAnimationConsentS2CPayload; +import com.nonid.network.PlayerAnimationRequestStatusS2CPayload; +import com.nonid.network.PlayerAnimationSelectionS2CPayload; +import com.nonid.network.PlayerAttackInputC2SPayload; +import com.nonid.network.PlayerProfileActionC2SPayload; +import com.nonid.network.PlayerProfileSnapshotS2CPayload; +import com.nonid.network.PlayerRuntimeSettingsS2CPayload; +import com.nonid.network.PregnancyCycleStateS2CPayload; +import com.nonid.network.PregnancyStateS2CPayload; +import com.nonid.network.RepairDestroyedSkinC2SPayload; +import com.nonid.network.ServerConfigRequestC2SPayload; +import com.nonid.network.ServerConfigResultS2CPayload; +import com.nonid.network.ServerConfigUpdateC2SPayload; +import com.nonid.network.SetPlayerGenderC2SPayload; +import com.nonid.network.SkinRippedInfoRequestS2CPayload; +import com.nonid.network.StartManualPeakC2SPayload; +import com.nonid.network.StopAttackAnimationC2SPayload; +import com.nonid.network.StopVoluntaryAnimationC2SPayload; +import com.nonid.particle.NonParticles; +import com.nonid.potion.NonBrewingRecipes; +import com.nonid.potion.NonPotions; +import com.nonid.recipe.NonRecipeSerializers; +import com.nonid.sound.NonSoundEvents; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDamage; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDeath; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AllowDamage; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AllowDeath; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents.AfterRespawn; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents.CopyFrom; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.EndDataPackReload; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStarted; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStopped; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.EndWorldTick; +import net.fabricmc.fabric.api.event.player.AttackEntityCallback; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.fabricmc.fabric.api.event.player.UseItemCallback; +import net.fabricmc.fabric.api.itemgroup.v1.FabricItemGroup; +import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents; +import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents.ModifyEntriesAll; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Disconnect; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Join; +import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry; +import net.minecraft.block.Blocks; +import net.minecraft.component.ComponentType; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.ExperienceOrbEntity; +import net.minecraft.entity.LazyEntityReference; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.SpawnGroup; +import net.minecraft.entity.SpawnReason; +import net.minecraft.entity.EntityType.Builder; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.mob.Angerable; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.passive.AbstractHorseEntity; +import net.minecraft.entity.passive.AnimalEntity; +import net.minecraft.entity.passive.HorseColor; +import net.minecraft.entity.passive.HorseEntity; +import net.minecraft.entity.passive.HorseMarking; +import net.minecraft.entity.passive.PassiveEntity; +import net.minecraft.entity.passive.TameableEntity; +import net.minecraft.entity.passive.FoxEntity.Variant; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemGroup; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.item.Item.Settings; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.registry.RegistryKey; +import net.minecraft.registry.RegistryKeys; +import net.minecraft.registry.entry.LazyRegistryEntryReference; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.registry.tag.FluidTags; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.PlayerConfigEntry; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.DyeColor; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class NeedsOfNature implements ModInitializer { + public static final String MOD_ID = "needsofnature"; + public static final int MAX_ENERGY = 200; + private static final int PLAYER_ANIMATION_START_ENERGY_GAIN = 20; + public static final float MIN_ENERGY_GAIN_MULT = 0.4F; + public static final float MAX_ENERGY_GAIN_MULT = 6.0F; + public static final String ENERGY_NBT_KEY = "NeedsOfNatureEnergy"; + public static final String ENERGY_GAIN_MULT_NBT_KEY = "NeedsOfNatureEnergyGainMult"; + public static final String ENERGY_GAIN_DRIFT_NBT_KEY = "NeedsOfNatureEnergyGainDrift"; + public static final String GENDER_NBT_KEY = "NeedsOfNatureGender"; + public static final String LIQUID_NBT_KEY = "NeedsOfNatureLiquid"; + public static final String LIQUID_TYPE_NBT_KEY = "NeedsOfNatureLiquidType"; + public static final String LIQUID_DONOR_VARIANT_NBT_KEY = "NeedsOfNatureLiquidDonorVariantData"; + public static final String LIQUID_DONOR_HISTORY_NBT_KEY = "NeedsOfNatureLiquidDonorHistory"; + public static final String LIQUID_TYPE_MIXED = "mixed"; + public static final String LIQUID_COLOR_MIXED_KEY = "needsofnature:mixed"; + public static final String PREGNANCY_TYPE_NBT_KEY = "NeedsOfNaturePregnancyType"; + public static final String PREGNANCY_END_TICK_NBT_KEY = "NeedsOfNaturePregnancyEndTick"; + public static final String PREGNANCY_PENDING_NBT_KEY = "NeedsOfNaturePregnancyPending"; + public static final String PREGNANCY_VARIANT_NBT_KEY = "NeedsOfNaturePregnancyVariantData"; + public static final String PREGNANCY_OFFSPRING_COUNT_NBT_KEY = "NeedsOfNaturePregnancyOffspringCount"; + public static final String PREGNANCY_CYCLE_PROGRESS_NBT_KEY = "NeedsOfNaturePregnancyCycleProgress"; + public static final String PREGNANCY_CYCLE_LAST_GAME_TICK_NBT_KEY = "NeedsOfNaturePregnancyCycleLastGameTick"; + public static final String PREGNANCY_CYCLE_LAST_TIME_OF_DAY_NBT_KEY = "NeedsOfNaturePregnancyCycleLastTimeOfDay"; + public static final String DESTROYED_SKIN_STAGE_NBT_KEY = "NeedsOfNatureDestroyedSkinStage"; + public static final String DESTROYED_SKIN_DAMAGE_NBT_KEY = "NeedsOfNatureDestroyedSkinDamage"; + public static final String MESS_V_NBT_KEY = "NeedsOfNatureMessV"; + public static final String MESS_A_NBT_KEY = "NeedsOfNatureMessA"; + public static final String MESS_M_NBT_KEY = "NeedsOfNatureMessM"; + public static final float FILLED_STAGE_ONE_THRESHOLD = 0.3F; + public static final float FILLED_STAGE_TWO_THRESHOLD = 0.5F; + public static final float FILLED_STAGE_THREE_THRESHOLD = 0.8F; + public static final float FILLED_STAGE_ONE_SPEED_MULT = 0.8F; + public static final float FILLED_STAGE_TWO_SPEED_MULT = 0.7F; + public static final float FILLED_STAGE_THREE_SPEED_MULT = 0.6F; + public static final float FILLED_STAGE_ONE_JUMP_MULT = 0.9F; + public static final float FILLED_STAGE_TWO_JUMP_MULT = 0.75F; + public static final float FILLED_STAGE_THREE_JUMP_MULT = 0.6F; + public static final float PREGNANCY_MIN_MOVEMENT_MULT = 0.5F; + public static final float PREGNANCY_SLOWDOWN_CURVE_POWER = 5.0F; + public static final Logger LOGGER = LoggerFactory.getLogger("needsofnature"); + private static final Gson HOST_CONFIG_SYNC_GSON = new Gson(); + public static final RegistryKey DEBUG_STAFF_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("debug_staff")); + public static final Item DEBUG_STAFF = registerItem(DEBUG_STAFF_KEY, new DebugStaffItem(new Settings().registryKey(DEBUG_STAFF_KEY).maxCount(1))); + public static final RegistryKey FLOWER_MIX_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("flower_mix")); + public static final Item FLOWER_MIX = registerItem(FLOWER_MIX_KEY, new Item(new Settings().registryKey(FLOWER_MIX_KEY))); + public static final RegistryKey ENERGY_AUGMENTER_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("energy_augmenter")); + public static final Item ENERGY_AUGMENTER = registerItem( + ENERGY_AUGMENTER_KEY, new EnergyGainAdjustItem(new Settings().registryKey(ENERGY_AUGMENTER_KEY), 0.2F) + ); + public static final RegistryKey ENERGY_DIMINISHER_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("energy_diminisher")); + public static final Item ENERGY_DIMINISHER = registerItem( + ENERGY_DIMINISHER_KEY, new EnergyGainAdjustItem(new Settings().registryKey(ENERGY_DIMINISHER_KEY), -0.2F) + ); + public static final RegistryKey ENERGY_STABILIZER_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("energy_stabilizer")); + public static final Item ENERGY_STABILIZER = registerItem(ENERGY_STABILIZER_KEY, new EnergyStabilizerItem(new Settings().registryKey(ENERGY_STABILIZER_KEY))); + public static final List DATA_DRIVEN_ACCESSORIES = registerDataDrivenAccessories(); + public static final RegistryKey> HORSE_LIQUID_COLLECTOR_ENTITY_TYPE_KEY = RegistryKey.of( + Registries.ENTITY_TYPE.getKey(), id("horse_liquid_collector") + ); + public static final EntityType HORSE_LIQUID_COLLECTOR_ENTITY_TYPE = (EntityType)Registry.register( + Registries.ENTITY_TYPE, + id("horse_liquid_collector"), + Builder.create(HorseLiquidCollectorEntity::new, SpawnGroup.MISC).dimensions(0.8F, 1.2F).build(HORSE_LIQUID_COLLECTOR_ENTITY_TYPE_KEY) + ); + public static final RegistryKey> PREGNANCY_EGG_ENTITY_TYPE_KEY = RegistryKey.of(Registries.ENTITY_TYPE.getKey(), id("pregnancy_egg")); + public static final EntityType PREGNANCY_EGG_ENTITY_TYPE = (EntityType)Registry.register( + Registries.ENTITY_TYPE, + id("pregnancy_egg"), + Builder.create(PregnancyEggEntity::new, SpawnGroup.MISC).dimensions(0.3F, 0.3F).build(PREGNANCY_EGG_ENTITY_TYPE_KEY) + ); + public static final RegistryKey HORSE_LIQUID_COLLECTOR_KEY = RegistryKey.of(Registries.ITEM.getKey(), id("horse_liquid_collector")); + public static final Item HORSE_LIQUID_COLLECTOR = registerItem( + HORSE_LIQUID_COLLECTOR_KEY, new HorseLiquidCollectorItem(new Settings().registryKey(HORSE_LIQUID_COLLECTOR_KEY).maxCount(1)) + ); + private static final Map, NeedsOfNature.ScanBudget> SCAN_BUDGETS = new ConcurrentHashMap<>(); + private static final Map PENDING_POST_DEATH_STATE_SYNCS = new ConcurrentHashMap<>(); + private static final ItemGroup ITEM_GROUP = (ItemGroup)Registry.register( + Registries.ITEM_GROUP, + id("main"), + FabricItemGroup.builder() + .displayName(Text.translatable("itemGroup.needsofnature.main")) + .icon(() -> new ItemStack(HORSE_LIQUID_COLLECTOR)) + .entries((context, entries) -> { + entries.add(FLOWER_MIX); + entries.add(ENERGY_AUGMENTER); + entries.add(ENERGY_DIMINISHER); + DATA_DRIVEN_ACCESSORIES.forEach(entries::add); + entries.add(HORSE_LIQUID_COLLECTOR); + NonItemSystem.addEntityLiquidAndStabilizersToItemGroup(entries, getConfig()); + NonItemSystem.addNonPotionVariantsToItemGroup(entries); + }) + .build() + ); + private static NonConfig CONFIG; + private static volatile HostConfigSyncData REMOTE_HOST_RULES; + private static volatile NonConfig REMOTE_EFFECTIVE_CONFIG; + private static final AtomicLong SERVER_CONFIG_REVISION = new AtomicLong(); + private static final AtomicLong ANIMATION_CATALOG_REVISION = new AtomicLong(); + private static final int ANIMATION_CATALOG_CHUNK_CHARS = 24000; + private static final Map PEAK_STATES = new ConcurrentHashMap<>(); + private static final int PEAK_XP_CAP = 20; + private static final Identifier PLAYER_ENTITY_TYPE_ID = Identifier.of("minecraft", "player"); + private static final int PENDING_PREGNANCY_EFFECT_TICKS = 21; + private static final int MESS_AURA_MAX_TOTAL = 15; + private static final double MESS_AURA_MAX_MULTIPLIER = 10.0; + private static final int GATHER_TIMEOUT_COOLDOWN_TICKS = 1200; + private static final int GATHER_PAIR_TRACK_TICKS = 6000; + private static final int BREEDING_PENDING_TIMEOUT_TICKS = 600; + private static final int REGULAR_PRUNE_INTERVAL_TICKS = 20; + private static final int FERTILITY_INCREASER_I_MIN_CHANCE = 80; + private static final int FERTILITY_INCREASER_II_MIN_CHANCE = 95; + private static final double LIQUID_SNEAK_DECAY_MULTIPLIER = 16.0; + private static final double LIQUID_WATER_DECAY_MULTIPLIER = 2.0; + private static final double LIQUID_DECAY_MIN_MULT = 0.1; + private static final double LIQUID_DECAY_MAX_MULT = 1.5; + private static final double NEAR_ANIMATION_RADIUS = 20.0; + private static final Map PENDING_GATHER_PAIRS = new ConcurrentHashMap<>(); + private static final Map NON_PENDING_GATHER_ACTORS = new ConcurrentHashMap<>(); + private static final String ANIMATION_TAG_DEFEAT_PREFIX = "defeat."; + private static final String ANIMATION_TAG_MANUAL_PEAK = "manualPeak"; + private static final String ANIMATION_TAG_FILL_BOTTLE = "fillBottle"; + private static final String ANIMATION_TAG_BIRTH = "birth"; + private static final String ANIMATION_TAG_BIRTH_PREFIX = "birth_"; + private static final int BIRTH_LOOP_STAGE = 2; + private static final int BIRTH_FINAL_STAGE = 3; + private static final double BIRTH_LOOP_MAX_SPEED_MULTIPLIER = 2.0; + private static final double BIRTH_LOOP_SPEED_TAPER_EXPONENT = 2.5; + static final String NON_PREGNANCY_FRIENDLY_TAG = "non.pregnancy_friendly"; + public static final String NON_ENERGY_STABILIZED_TAG = "non.energy_stabilized"; + private static final Set DEFEATED_QUEUED_INSTANCES = ConcurrentHashMap.newKeySet(); + private static final Map LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER = new ConcurrentHashMap<>(); + private static final Map POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER = new ConcurrentHashMap<>(); + private static final int ATTACK_ESCAPE_DAMAGE_DELAY_TICKS = 1; + private static final Map PENDING_ATTACK_ESCAPE_RESOLUTIONS = new ConcurrentHashMap<>(); + private static final List PREGNANCY_VARIANT_COMPONENTS = List.of( + registryVariant("wolf_variant", DataComponentTypes.WOLF_VARIANT, RegistryKeys.WOLF_VARIANT), + registryVariant("wolf_sound_variant", DataComponentTypes.WOLF_SOUND_VARIANT, RegistryKeys.WOLF_SOUND_VARIANT), + registryVariant("pig_variant", DataComponentTypes.PIG_VARIANT, RegistryKeys.PIG_VARIANT), + registryVariant("cow_variant", DataComponentTypes.COW_VARIANT, RegistryKeys.COW_VARIANT), + lazyRegistryVariant("chicken_variant", DataComponentTypes.CHICKEN_VARIANT, RegistryKeys.CHICKEN_VARIANT), + registryVariant("frog_variant", DataComponentTypes.FROG_VARIANT, RegistryKeys.FROG_VARIANT), + registryVariant("cat_variant", DataComponentTypes.CAT_VARIANT, RegistryKeys.CAT_VARIANT), + lazyRegistryVariant("zombie_nautilus_variant", DataComponentTypes.ZOMBIE_NAUTILUS_VARIANT, RegistryKeys.ZOMBIE_NAUTILUS_VARIANT), + enumVariant("fox_variant", DataComponentTypes.FOX_VARIANT, Variant.class), + enumVariant("salmon_size", DataComponentTypes.SALMON_SIZE, net.minecraft.entity.passive.SalmonEntity.Variant.class), + enumVariant("parrot_variant", DataComponentTypes.PARROT_VARIANT, net.minecraft.entity.passive.ParrotEntity.Variant.class), + enumVariant("mooshroom_variant", DataComponentTypes.MOOSHROOM_VARIANT, net.minecraft.entity.passive.MooshroomEntity.Variant.class), + enumVariant("rabbit_variant", DataComponentTypes.RABBIT_VARIANT, net.minecraft.entity.passive.RabbitEntity.Variant.class), + enumVariant("horse_variant", DataComponentTypes.HORSE_VARIANT, HorseColor.class), + enumVariant("llama_variant", DataComponentTypes.LLAMA_VARIANT, net.minecraft.entity.passive.LlamaEntity.Variant.class), + enumVariant("axolotl_variant", DataComponentTypes.AXOLOTL_VARIANT, net.minecraft.entity.passive.AxolotlEntity.Variant.class), + enumVariant("sheep_color", DataComponentTypes.SHEEP_COLOR, DyeColor.class), + enumVariant("tropical_fish_base_color", DataComponentTypes.TROPICAL_FISH_BASE_COLOR, DyeColor.class), + enumVariant("tropical_fish_pattern_color", DataComponentTypes.TROPICAL_FISH_PATTERN_COLOR, DyeColor.class) + ); + private static final int INFINITE_PEAK_LIQUID_PULSE_ML = 5; + private static final Map PEAK_LIQUID_PROGRESS = new ConcurrentHashMap<>(); + private static final Map PEAK_XP_ELIGIBLE_ML = new ConcurrentHashMap<>(); + private static final Map FILL_BOTTLE_STATES = new ConcurrentHashMap<>(); + private static final Map BREEDING_PENDING_BY_PAIR = new ConcurrentHashMap<>(); + private static final Map BREEDING_PENDING_BY_REQUEST = new ConcurrentHashMap<>(); + private static final Map BREEDING_ACTIVE_BY_INSTANCE = new ConcurrentHashMap<>(); + private static final Map BREEDING_ACTIVE_INSTANCE_BY_PAIR = new ConcurrentHashMap<>(); + private static final Map PREGNANCY_HATCH_BY_INSTANCE = new ConcurrentHashMap<>(); + private static final Map PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER = new ConcurrentHashMap<>(); + private static final Set PREGNANCY_QUEUED_PLAYERS = ConcurrentHashMap.newKeySet(); + private static final Map PREGNANCY_START_RETRY_TICKS = new ConcurrentHashMap<>(); + private static final Map PREGNANCY_CUE_RUNTIME_BY_INSTANCE = new ConcurrentHashMap<>(); + private static final Set REFLECTION_FAILURE_LOGS = ConcurrentHashMap.newKeySet(); + private static final Map RECEIVER_DRIP = new ConcurrentHashMap<>(); + private static final Map PENDING_M_INJECTOR_REWARDS = new ConcurrentHashMap<>(); + private static final int RECEIVER_DRIP_INTERVAL_TICKS = 20; + private static final int RECEIVER_DRIP_DURATION_TICKS = 200; + private static final float FILL_BOTTLE_LOOK_DOWN_MIN_PITCH = 80.0F; + private static final Vec3d RECEIVER_DRIP_OFFSET_DEFAULT = new Vec3d(0.0, 0.0, -0.5); + private static final Map RECEIVER_DRIP_OFFSETS = Map.of(Identifier.of("minecraft", "wolf"), new Vec3d(0.0, 0.0, -0.5)); + + public static float getPregnancyMovementMultiplier(int remainingTicks) { + if (remainingTicks == -1) { + return 1.0F; + } + + if (remainingTicks <= 22) { + return 0.5F; + } + + long configuredTotalTicks = Math.max(1L, getConfig().getPregnancyDurationMinutes() * 1200L); + double remaining01 = MathHelper.clamp((double)remainingTicks / configuredTotalTicks, 0.0, 1.0); + double closeness = 1.0 - remaining01; + double curve = Math.pow(closeness, 5.0); + double multiplier = 1.0 - 0.5 * curve; + return (float)MathHelper.clamp(multiplier, 0.5, 1.0); + } + + public static void applyAnimationToggleConfig() { + NonConfig config = CONFIG; + if (config == null) { + AnimationDefinitions.setDisabledAnimationFilters(Set.of(), Set.of()); + } else { + LinkedHashSet disabledAnimations = new LinkedHashSet<>(); + + for (String raw : config.getDisabledAnimations()) { + Identifier id = Identifier.tryParse(raw); + if (id != null) { + disabledAnimations.add(id); + } + } + + AnimationDefinitions.setDisabledAnimationFilters(disabledAnimations, config.getDisabledAnimationPacks()); + } + } + + public void onInitialize() { + NonWildfireGenderConfigCompat.initialize(); + NonDiagnostics.initialize(); + AnimationEngine.initialize(); + CONFIG = NonConfig.load(); + NonActorEvents.PROVIDE + .register((NonActorEvents.Provider)entity -> entity instanceof LivingEntity living ? NonTrinketsIntegration.resolveVirtualActorTags(living) : Set.of()); + applyAnimationToggleConfig(); + NonStats.registerBaseStats(); + NonSoundEvents.registerAll(); + NonParticles.registerAll(); + NonStatusEffects.registerAll(); + ServerLifecycleEvents.SERVER_STARTED.register((ServerStarted)server -> { + NonStats.registerKnownEntityStats(); + SERVER_CONFIG_REVISION.set(0L); + ANIMATION_CATALOG_REVISION.set(0L); + clearServerRuntimeState(); + }); + ServerLifecycleEvents.SERVER_STOPPED.register((ServerStopped)server -> clearServerRuntimeState()); + ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((EndDataPackReload)(server, resourceManager, success) -> { + if (success) { + SERVER_CONFIG_REVISION.incrementAndGet(); + ANIMATION_CATALOG_REVISION.incrementAndGet(); + syncHostConfigStateToAll(server); + syncAnimationCatalogStateToAll(server); + } + }); + NonPotions.registerAll(); + NonBrewingRecipes.registerAll(); + NonRecipeSerializers.registerAll(); + NonTrinketsIntegration.registerPredicates(); + FabricDefaultAttributeRegistry.register(HORSE_LIQUID_COLLECTOR_ENTITY_TYPE, HorseLiquidCollectorEntity.createAttributes()); + FabricDefaultAttributeRegistry.register(PREGNANCY_EGG_ENTITY_TYPE, PregnancyEggEntity.createAttributes()); + PayloadTypeRegistry.playC2S().register(StopVoluntaryAnimationC2SPayload.ID, StopVoluntaryAnimationC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(StopAttackAnimationC2SPayload.ID, StopAttackAnimationC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(PlayerAttackInputC2SPayload.ID, PlayerAttackInputC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(StartManualPeakC2SPayload.ID, StartManualPeakC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(SetPlayerGenderC2SPayload.ID, SetPlayerGenderC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(RepairDestroyedSkinC2SPayload.ID, RepairDestroyedSkinC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(ManualDamageDestroyedSkinC2SPayload.ID, ManualDamageDestroyedSkinC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DestroyedSkinUploadC2SPayload.ID, DestroyedSkinUploadC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DestroyedSkinMaskUploadC2SPayload.ID, DestroyedSkinMaskUploadC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(PlayerProfileActionC2SPayload.ID, PlayerProfileActionC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(PlayerAnimationChoiceC2SPayload.ID, PlayerAnimationChoiceC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(PlayerAnimationConsentC2SPayload.ID, PlayerAnimationConsentC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(PlayerAnimationCancelC2SPayload.ID, PlayerAnimationCancelC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(ServerConfigRequestC2SPayload.ID, ServerConfigRequestC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(ServerConfigUpdateC2SPayload.ID, ServerConfigUpdateC2SPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AttackStateS2CPayload.ID, AttackStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(LiquidStateS2CPayload.ID, LiquidStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PregnancyStateS2CPayload.ID, PregnancyStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PregnancyCycleStateS2CPayload.ID, PregnancyCycleStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PlayerRuntimeSettingsS2CPayload.ID, PlayerRuntimeSettingsS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(GenderSelectionPromptS2CPayload.ID, GenderSelectionPromptS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(HostConfigSyncS2CPayload.ID, HostConfigSyncS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(ServerConfigResultS2CPayload.ID, ServerConfigResultS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AnimationCatalogChunkS2CPayload.ID, AnimationCatalogChunkS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(DestroyedSkinSyncS2CPayload.ID, DestroyedSkinSyncS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(DestroyedSkinMaskSyncS2CPayload.ID, DestroyedSkinMaskSyncS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(DestroyedSkinStateS2CPayload.ID, DestroyedSkinStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(DestroyedSkinParticlesS2CPayload.ID, DestroyedSkinParticlesS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(MessStateS2CPayload.ID, MessStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AccessoryShedStateS2CPayload.ID, AccessoryShedStateS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(SkinRippedInfoRequestS2CPayload.ID, SkinRippedInfoRequestS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PlayerProfileSnapshotS2CPayload.ID, PlayerProfileSnapshotS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PlayerAnimationSelectionS2CPayload.ID, PlayerAnimationSelectionS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PlayerAnimationConsentS2CPayload.ID, PlayerAnimationConsentS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(PlayerAnimationRequestStatusS2CPayload.ID, PlayerAnimationRequestStatusS2CPayload.CODEC); + NonPlayerInteractionSystem.register(); + NonAnimationRescueSystem.register(); + NonPlayerAttackSystem.register(); + registerServerSettingsNetworking(); + NonGenderSystem.registerMobGenderTags(); + NonEntityProfiles.registerReloadListener(); + NonLiquidGainOverrides.registerReloadListener(); + this.registerDebugStaffInteraction(); + this.registerEnergyControlItemInteraction(); + this.registerPlayerAnimationJoinInteraction(); + NonGenderSystem.registerCowMilkingGenderGate(); + this.registerHorseLiquidCollectorBottleInteraction(); + this.registerHorseLiquidCollectorDrops(); + NonDestroyedSkinSystem.registerDestroyedSkinRepairNetworking(); + this.registerDebugCommands(); + this.registerLiquidPotionGroupCleanup(); + this.registerAnimationListeners(); + this.registerLoopProgression(); + NonDestroyedSkinSystem.registerDestroyedSkinNetworking(); + this.registerVoluntaryStopRequests(); + this.registerAttackStopRequests(); + this.registerManualPeakRequests(); + this.registerNormalDamageDestroyedSkinDamage(); + NonGenderSystem.registerPlayerGenderRequests(); + this.registerFillBottleUseTrigger(); + this.registerLastDefeatedDeathRescue(); + this.registerPregnancyDeathCleanup(); + this.registerDeathPersistenceCopy(); + this.registerPostEscapeDamageInvulnerability(); + this.registerDirectAttackBlockCleanup(); + this.registerLiquidSync(); + } + + private void registerDebugStaffInteraction() { + AttackEntityCallback.EVENT.register((AttackEntityCallback)(player, world, hand, entity, hitResult) -> { + if (hand != Hand.MAIN_HAND || player.getStackInHand(hand).getItem() != DEBUG_STAFF) { + return ActionResult.PASS; + } + + if (world.isClient()) { + return ActionResult.PASS; + } + + if (entity instanceof PlayerEntity) { + return ActionResult.FAIL; + } + + if (!entity.isRemoved()) { + world.playSound(null, entity.getX(), entity.getY(), entity.getZ(), NonSoundEvents.DEBUG_STAFF_DISAPPEAR, SoundCategory.NEUTRAL, 1.0F, 1.0F); + entity.discard(); + } + + return ActionResult.SUCCESS; + }); + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (entity instanceof LivingEntity living) { + ItemStack stack = player.getStackInHand(hand); + if (stack.isEmpty() || stack.getItem() != DEBUG_STAFF) { + return ActionResult.PASS; + } + + if (!(living instanceof EnergyHolder)) { + return ActionResult.PASS; + } + + if (world.isClient()) { + return ActionResult.SUCCESS; + } + + ActionResult result = stack.getItem().useOnEntity(stack, player, living, hand); + return (ActionResult)(result == ActionResult.PASS ? ActionResult.SUCCESS : result); + } else { + return ActionResult.PASS; + } + }); + } + + private void registerEnergyControlItemInteraction() { + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (entity instanceof LivingEntity living) { + ItemStack stack = player.getStackInHand(hand); + if (stack.isEmpty()) { + return ActionResult.PASS; + } + + if (!(stack.getItem() instanceof EnergyStabilizerItem) && !(stack.getItem() instanceof EnergyGainAdjustItem)) { + return ActionResult.PASS; + } + + if (!(living instanceof EnergyHolder)) { + return ActionResult.PASS; + } + + ActionResult result = stack.getItem().useOnEntity(stack, player, living, hand); + return (ActionResult)(result == ActionResult.PASS ? ActionResult.SUCCESS : result); + } else { + return ActionResult.PASS; + } + }); + } + + private void registerPlayerAnimationJoinInteraction() { + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (!(world instanceof ServerWorld serverWorld && player instanceof ServerPlayerEntity joiningPlayer)) { + return ActionResult.PASS; + } else if (hand != Hand.MAIN_HAND) { + return ActionResult.PASS; + } else if (!joiningPlayer.getStackInHand(Hand.MAIN_HAND).isEmpty()) { + return ActionResult.PASS; + } else if (joiningPlayer.isSpectator()) { + return ActionResult.PASS; + } else if (entity != null && !entity.isRemoved() && !entity.getUuid().equals(joiningPlayer.getUuid())) { + return (ActionResult)(NonPlayerInteractionSystem.handleInteraction(serverWorld, joiningPlayer, entity) ? ActionResult.SUCCESS : ActionResult.PASS); + } else { + return ActionResult.PASS; + } + }); + } + + private void registerHorseLiquidCollectorBottleInteraction() { + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (entity instanceof HorseLiquidCollectorEntity collector) { + ItemStack stack = player.getStackInHand(hand); + if (stack.isEmpty() || !stack.isOf(Items.GLASS_BOTTLE)) { + return ActionResult.PASS; + } + + if (world.isClient()) { + return ActionResult.SUCCESS; + } + + if (player instanceof ServerPlayerEntity serverPlayer) { + if (!collector.isCollectorFull()) { + return ActionResult.SUCCESS; + } + + if (!NonItemSystem.consumeOneGlassBottle(serverPlayer, hand)) { + return ActionResult.SUCCESS; + } + + ItemStack resultBottle = NonItemSystem.createLiquidBottleStack(collector.getBottleLiquidEntityTypeId()); + collector.clearStoredLiquid(); + NonItemSystem.giveItemOrDrop(serverPlayer, resultBottle); + world.playSound(null, collector.getX(), collector.getY(), collector.getZ(), SoundEvents.ITEM_BOTTLE_FILL, SoundCategory.PLAYERS, 1.0F, 1.0F); + return ActionResult.SUCCESS; + } else { + return ActionResult.SUCCESS; + } + } else { + return ActionResult.PASS; + } + }); + } + + private void registerHorseLiquidCollectorDrops() { + ServerLivingEntityEvents.AFTER_DEATH.register((AfterDeath)(entity, damageSource) -> { + if (entity instanceof HorseLiquidCollectorEntity) { + if (entity.getEntityWorld() instanceof ServerWorld world) { + if (!(damageSource.getAttacker() instanceof ServerPlayerEntity player && player.isCreative())) { + entity.dropStack(world, new ItemStack(HORSE_LIQUID_COLLECTOR)); + } + } + } + }); + } + + private void registerDebugCommands() { + NonCommands.register(); + } + + public static Identifier id(String path) { + return Identifier.of("needsofnature", path); + } + + private static void grantNoNAdvancement(ServerPlayerEntity player, String path) { + NonAdvancementHooks.grant(player, path); + } + + public static void grantEmergencySnackAdvancement(ServerPlayerEntity player) { + NonAdvancementHooks.grantEmergencySnack(player); + } + + public static void grantUdderlyUnfortunateAdvancementIfPregnant(ServerPlayerEntity player) { + if (getPregnantEntityTypeId(player) != null) { + NonAdvancementHooks.grantUdderlyUnfortunate(player); + } + } + + public static void resetStableDietStreak(ServerPlayerEntity player) { + NonAdvancementHooks.resetStableDietStreak(player); + } + + private static void checkLiquidAdvancements(ServerPlayerEntity player, LiquidHolder holder) { + NonAdvancementHooks.checkLiquidAdvancements(player, holder); + } + + private static void trackFieldResearch(ServerWorld world, ServerPlayerEntity player, Map perEntityAmounts) { + NonAdvancementHooks.trackFieldResearch(world, player, perEntityAmounts); + } + + static void checkBadIdeaBeaconAdvancement(ServerPlayerEntity player) { + NonAdvancementHooks.checkBadIdeaBeacon(player); + } + + static boolean isMessSystemEnabled() { + NonConfig config = getConfig(); + return config == null || config.isMessSystemEnabled(); + } + + static boolean isDestroyedSkinSystemEnabled() { + NonConfig config = getConfig(); + return config == null || config.isDestroyedSkinSystemEnabled(); + } + + static int getDestroyedSkinStageForPlayer(ServerPlayerEntity player) { + return NonDestroyedSkinSystem.getDestroyedSkinStageForPlayer(player); + } + + static int getTotalMessForPlayer(ServerPlayerEntity player) { + if (player == null || !isMessSystemEnabled()) { + return 0; + } else { + return player instanceof MessHolder holder + ? NonMessSystem.clampMess(holder.getVMess()) + NonMessSystem.clampMess(holder.getAMess()) + NonMessSystem.clampMess(holder.getMMess()) + : 0; + } + } + + public static UUID getActivePlayerInstance(ServerPlayerEntity player) { + return player != null && player.getEntityWorld() instanceof ServerWorld world + ? ServerAnimationController.findActiveInstanceId(world, player.getUuid()) + : null; + } + + public static boolean hasActivePlayerAnimation(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + UUID active = getActivePlayerInstance(player); + return active != null; + } + + public static boolean instanceContainsActor(ServerWorld world, UUID instanceId, UUID actorUuid) { + if (world != null && instanceId != null && actorUuid != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + return snapshot != null && snapshot.actorUuids().contains(actorUuid); + } else { + return false; + } + } + + public static boolean hasNearbyVisibleAnimation(ServerWorld world, MobEntity viewer) { + return getNearbyVisibleAnimationGainMultiplier(world, viewer) > 1.0; + } + + public static double getNearbyVisibleAnimationGainMultiplier(ServerWorld world, MobEntity viewer) { + if (world != null && viewer != null) { + double maxDistSq = 400.0; + UUID viewerUuid = viewer.getUuid(); + double best = 1.0; + + for (ServerAnimationController.ActiveInstanceSnapshot snapshot : ServerAnimationController.getActiveInstanceSnapshots(world)) { + List actorUuids = snapshot.actorUuids(); + if (actorUuids != null && !actorUuids.isEmpty() && !actorUuids.contains(viewerUuid)) { + for (UUID actorUuid : actorUuids) { + if (actorUuid != null + && world.getEntity(actorUuid) instanceof LivingEntity living + && living.isAlive() + && !living.isRemoved() + && !(viewer.squaredDistanceTo(living) > maxDistSq) + && viewer.canSee(living)) { + double multiplier = getNearAnimationGainMult(); + if (living instanceof ServerPlayerEntity player) { + multiplier *= NonTrinketsIntegration.getAccessoryEffects(player).nearAnimationMobEnergyMultiplier(); + } + + best = Math.max(best, multiplier); + } + } + } + } + + return best; + } else { + return 1.0; + } + } + + public static float getNearAnimationGainMult() { + NonConfig config = getConfig(); + return config == null ? 3.0F : (float)config.getNearAnimationEnergyGainMult(); + } + + public static float getNearAnimationGainMult(@Nullable ServerPlayerEntity player) { + float base = getNearAnimationGainMult(); + return player == null ? base : (float)(base * NonTrinketsIntegration.getAccessoryEffects(player).nearAnimationMobEnergyMultiplier()); + } + + public static void tickPlayerEnergyAura(ServerWorld world, ServerPlayerEntity player) { + NonAuraSystem.tickPlayerEnergyAura(world, player); + } + + public static NeedsOfNature.PlayerEnergyAuraDebug getPlayerEnergyAuraDebug(ServerWorld world, MobEntity mob, EnergyHolder holder) { + NonAuraSystem.PlayerEnergyAuraDebug debug = NonAuraSystem.getPlayerEnergyAuraDebug(world, mob, holder); + return debug == null + ? null + : new NeedsOfNature.PlayerEnergyAuraDebug( + debug.player(), debug.totalMultiplier(), debug.playerEnergy(), debug.destroyedSkinStage(), debug.messTotal(), debug.radius() + ); + } + + private static Item registerItem(RegistryKey key, Item item) { + return (Item)Registry.register(Registries.ITEM, key, item); + } + + private static List registerDataDrivenAccessories() { + NonAccessoryItemRegistry.registerStartupItems(); + NonAccessoryDefinitions.loadStartupDefinitions(); + return NonAccessoryItemRegistry.getRegisteredItems(); + } + + private void registerLiquidPotionGroupCleanup() { + ItemGroupEvents.MODIFY_ENTRIES_ALL.register((ModifyEntriesAll)(group, entries) -> { + Identifier groupId = Registries.ITEM_GROUP.getId(group); + if (groupId == null || !groupId.equals(id("main"))) { + entries.getDisplayStacks().removeIf(NonItemSystem::isNonPotionVariantStack); + entries.getSearchTabStacks().removeIf(NonItemSystem::isNonPotionVariantStack); + } + }); + } + + private void registerPregnancyDeathCleanup() { + ServerLivingEntityEvents.AFTER_DEATH.register((AfterDeath)(entity, damageSource) -> { + if (entity instanceof ServerPlayerEntity player) { + NonConfig config = getConfig(); + if (config != null && config.keepPregnancyAfterDeath()) { + return; + } + + clearPregnancyOnDeath(player); + } + }); + } + + private void registerDeathPersistenceCopy() { + ServerPlayerEvents.COPY_FROM.register((CopyFrom)(oldPlayer, newPlayer, alive) -> { + if (!alive) { + copyDeathPersistentPlayerState(oldPlayer, newPlayer); + } + }); + ServerPlayerEvents.AFTER_RESPAWN.register((AfterRespawn)(oldPlayer, newPlayer, alive) -> { + if (!alive) { + NonPlayerAttackSystem.clearForRespawn(newPlayer); + schedulePostDeathStateSync(newPlayer); + } + }); + } + + private static void copyDeathPersistentPlayerState(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + if (oldPlayer != null && newPlayer != null) { + NonConfig config = getConfig(); + if (config != null) { + NonPregnancyConceptionSystem.copyState(oldPlayer, newPlayer); + if (config.isMessSystemEnabled()) { + if (config.keepMessAfterDeath()) { + NeedsOfNature.MessSnapshot.copy(oldPlayer, newPlayer); + } else { + NonMessSystem.clearMessStoredState(newPlayer); + } + } + + if (config.isDestroyedSkinSystemEnabled()) { + if (config.keepRippedSkinAfterDeath()) { + NeedsOfNature.RippedSkinSnapshot.copy(oldPlayer, newPlayer); + } else { + NonDestroyedSkinSystem.clearDestroyedSkinStoredState(newPlayer); + } + } + + if (config.isLiquidTankEnabled()) { + if (config.keepLiquidTankAfterDeath()) { + NeedsOfNature.LiquidSnapshot.copy(oldPlayer, newPlayer); + } else { + clearLiquidStoredState(newPlayer); + } + } + + if (config.isPregnancyEnabled() && config.keepPregnancyAfterDeath()) { + NeedsOfNature.PregnancySnapshot.copy(oldPlayer, newPlayer); + } + } + } + } + + private static void schedulePostDeathStateSync(ServerPlayerEntity player) { + if (player != null && player.getEntityWorld() instanceof ServerWorld world) { + PENDING_POST_DEATH_STATE_SYNCS.put(player.getUuid(), new NeedsOfNature.PostDeathStateSync(world.getRegistryKey(), world.getTime() + 2L)); + } + } + + private static void tickPostDeathStateSync(ServerWorld world, long now) { + if (world != null && !PENDING_POST_DEATH_STATE_SYNCS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (Entry entry : PENDING_POST_DEATH_STATE_SYNCS.entrySet()) { + UUID playerUuid = entry.getKey(); + NeedsOfNature.PostDeathStateSync sync = entry.getValue(); + if (playerUuid != null && sync != null) { + if (worldKey.equals(sync.worldKey()) && now >= sync.dueTick()) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(playerUuid); + if (player != null) { + syncPostDeathPlayerState(player); + } + + PENDING_POST_DEATH_STATE_SYNCS.remove(playerUuid, sync); + } + } else if (playerUuid != null) { + PENDING_POST_DEATH_STATE_SYNCS.remove(playerUuid); + } + } + } + } + + private static void syncPostDeathPlayerState(ServerPlayerEntity player) { + if (player != null) { + NonConfig config = getConfig(); + if (config != null) { + if (!config.keepMessAfterDeath()) { + NonMessSystem.clearMessStoredState(player); + } + + NonMessSystem.broadcastMessState(player); + if (!config.keepRippedSkinAfterDeath()) { + NonDestroyedSkinSystem.clearDestroyedSkinStoredState(player); + } + + NonDestroyedSkinSystem.broadcastDestroyedSkinStage(player); + if (!config.keepLiquidTankAfterDeath()) { + clearLiquidStoredState(player); + } + + syncLiquidState(player); + syncPregnancyState(player); + NonPregnancyConceptionSystem.syncState(player); + } + } + } + + private static void clearLiquidStoredState(ServerPlayerEntity player) { + if (player instanceof LiquidHolder holder) { + holder.setLiquidStored(0); + holder.clearLiquidComposition(); + } + } + + private void registerLastDefeatedDeathRescue() { + ServerLivingEntityEvents.ALLOW_DEATH + .register( + (AllowDeath)(entity, damageSource, damageAmount) -> { + if (entity instanceof ServerPlayerEntity player) { + if (player.getEntityWorld() instanceof ServerWorld world) { + ServerPlayerEntity damagingPlayer = NonPlayerAttackSystem.resolveAttributedPlayer(damageSource); + if (damagingPlayer != null && NonPlayerAttackSystem.isOwnerFinishingDamage(world, player, damageSource)) { + NonPlayerAttackSystem.onOwnerFinishingDeath(world, player); + return true; + } + + NonConfig config = getConfig(); + if (config == null || !config.isLastDefeatedEnabled()) { + return true; + } + + if (hasVanillaTotemReady(player)) { + return true; + } + + if (AnimationService.isActorPendingOrActive(world, player.getUuid())) { + return true; + } + + long nowTick = getGlobalTick(world); + Long cooldownUntil = LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.get(player.getUuid()); + if (cooldownUntil != null && cooldownUntil > nowTick) { + return true; + } + + if (damagingPlayer != null) { + if (damagingPlayer != player + && damagingPlayer.getEntityWorld() == world + && damagingPlayer.isAlive() + && !damagingPlayer.isRemoved() + && !damagingPlayer.isSpectator()) { + if (!(damagingPlayer instanceof EnergyHolder holder && holder.getEnergy() >= config.getLastDefeatedEnergyThreshold())) { + return true; + } else if (NonPlayerInteractionSystem.areAttacksAllowed(player, damagingPlayer) + && !AnimationService.isActorPendingOrActive(world, damagingPlayer.getUuid())) { + AnimationService.MatchedAnimation attackMatch = NonPlayerAttackSystem.findViableAttack(world, damagingPlayer, player); + if (attackMatch == null) { + return true; + } + + AnimationService.MatchedAnimation defeatMatch = NonDefeatedAnimationSelector.find(world, player, attackMatch.animationId()); + if (defeatMatch == null) { + return true; + } + + player.setHealth(1.0F); + UUID instanceId = startAnimationNowWithContext( + world, + defeatMatch.animationId(), + List.of(player), + defeatMatch.actorKeys(), + defeatMatch.stages(), + NonAnimationDamageBehavior.IGNORE_DAMAGE, + true, + player, + NonPlayerAttackSystem.buildPlayerAttackContext(player.getUuid(), damagingPlayer.getUuid(), Set.of(damagingPlayer.getUuid())) + ); + if (instanceId == null) { + player.setHealth(0.0F); + return true; + } + + int cooldownTicks = config.getLastDefeatedCooldownSeconds() * 20; + if (cooldownTicks > 0) { + LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.put(player.getUuid(), nowTick + cooldownTicks); + } + + logRuntime( + "[NoN] Last defeated rescued player={} from lethal player damage. attacker={} sourceAnimation={} defeatedAnimation={} instance={}", + player.getName().getString(), + damagingPlayer.getName().getString(), + attackMatch.animationId(), + defeatMatch.animationId(), + instanceId + ); + return false; + } else { + return true; + } + } else { + return true; + } + } else { + Entity attacker = damageSource == null ? null : damageSource.getAttacker(); + Entity sourceEntity = damageSource == null ? null : damageSource.getSource(); + MobEntity damagingMob = attacker instanceof MobEntity mob ? mob : (sourceEntity instanceof MobEntity mob ? mob : null); + if (damagingMob == null) { + return true; + } + + NeedsOfNature.LastDefeatedCandidate candidate = findLastDefeatedCandidate(world, player, damagingMob); + if (candidate == null) { + return true; + } + + player.setHealth(1.0F); + UUID instanceId = startAnimationNowWithContext( + world, + candidate.defeatMatch().animationId(), + List.of(player), + candidate.defeatMatch().actorKeys(), + candidate.defeatMatch().stages(), + NonAnimationDamageBehavior.BLOCK_DAMAGE, + true, + player, + buildModeContext(true) + ); + if (instanceId == null) { + player.setHealth(0.0F); + return true; + } + + int cooldownTicks = config.getLastDefeatedCooldownSeconds() * 20; + if (cooldownTicks > 0) { + LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.put(player.getUuid(), nowTick + cooldownTicks); + } + + logRuntime( + "[NoN] Last defeated rescued player={} from lethal mob damage. mob={} sourceAnimation={} defeatedAnimation={} instance={}", + player.getName().getString(), + Registries.ENTITY_TYPE.getId(candidate.mob().getType()), + candidate.gatherCandidate().animationId(), + candidate.defeatMatch().animationId(), + instanceId + ); + return false; + } + } else { + return true; + } + } else { + return true; + } + } + ); + } + + private void registerDirectAttackBlockCleanup() { + ServerLivingEntityEvents.ALLOW_DAMAGE.register((AllowDamage)(entity, source, amount) -> { + if (entity instanceof ServerPlayerEntity player) { + if (player.getEntityWorld() instanceof ServerWorld world) { + Entity attacker = source.getAttacker(); + if (attacker instanceof MobEntity mob) { + Entity directSource = source.getSource(); + if (directSource != null && directSource != attacker) { + return true; + } + + NeedsOfNature.AttackBlockReason reason = getMobAttackBlockReason(world, mob, player); + if (reason == null) { + return true; + } + + onMobAttackBlocked(world, mob, player, reason); + return false; + } else { + return true; + } + } else { + return true; + } + } else { + return true; + } + }); + } + + private void registerPostEscapeDamageInvulnerability() { + ServerLivingEntityEvents.ALLOW_DAMAGE.register((AllowDamage)(entity, source, amount) -> { + if (entity instanceof ServerPlayerEntity player) { + return player.getEntityWorld() instanceof ServerWorld world ? !isPostEscapeDamageInvulnerable(world, player) : true; + } else { + return true; + } + }); + } + + private static void setPostEscapeDamageInvulnerable(ServerPlayerEntity player, int ticks) { + if (player != null) { + if (player.getEntityWorld() instanceof ServerWorld world) { + if (ticks <= 0) { + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.remove(player.getUuid()); + } else { + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.put(player.getUuid(), world.getTime() + ticks); + } + } + } + } + + private static boolean isPostEscapeDamageInvulnerable(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + Long untilTick = POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.get(player.getUuid()); + if (untilTick == null) { + return false; + } else if (world.getTime() >= untilTick) { + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.remove(player.getUuid(), untilTick); + return false; + } else { + return true; + } + } else { + return false; + } + } + + private void registerNormalDamageDestroyedSkinDamage() { + ServerLivingEntityEvents.AFTER_DAMAGE.register((AfterDamage)(entity, source, baseDamageTaken, damageTaken, blocked) -> { + if (!blocked && !(damageTaken <= 0.0F)) { + if (entity instanceof ServerPlayerEntity player) { + if (player.getEntityWorld() instanceof ServerWorld world) { + if (isDestroyedSkinSystemEnabled()) { + if (!AnimationService.isActorPendingOrActive(world, player.getUuid())) { + int chance = getConfig().getNormalDamageDestroyedSkinChancePercent(); + if (chance > 0) { + if (chance >= 100 || world.getRandom().nextInt(100) < chance) { + NonDestroyedSkinSystem.applyDestroyedSkinAttackDamage(world, player); + } + } + } + } + } + } + } + }); + } + + private void registerAnimationListeners() { + AnimationLifecycleEvents.START + .register( + (AnimationLifecycleEvents.Start)event -> { + ServerWorld world = event.world(); + ServerAnimationController.ActiveInstanceSnapshot snapshot = event.snapshot(); + UUID instanceId = snapshot.instanceId(); + Identifier animationId = snapshot.animationId(); + List actorUuids = snapshot.actorUuids(); + List actorKeys = snapshot.actorKeys(); + List stages = snapshot.stages(); + long startTick = snapshot.startTick(); + clearPendingGatherPairsForActors(world, actorUuids); + boolean hasPlayer = false; + List playerActors = new ArrayList<>(); + + for (UUID uuid : actorUuids) { + if (world.getEntity(uuid) instanceof ServerPlayerEntity player) { + hasPlayer = true; + playerActors.add(player); + } + } + + AnimationStartContext startContext = snapshot.startContext(); + NonAnimationSessionData sessionData = getNonSessionData(startContext); + adoptPendingBreedingRequest(world, instanceId, sessionData); + adoptPendingPregnancyHatch(world, instanceId, actorUuids, sessionData); + boolean isPregnancy = hasPlayer && sessionData != null && sessionData.isPregnancy(); + boolean isAttack = hasPlayer && sessionData != null && sessionData.isAttack(); + boolean isDefeatAnimation = hasAnimationTagWithPrefix(animationId, "defeat."); + ServerPlayerEntity attackVictim = isAttack ? NonPlayerAttackSystem.resolveAttackVictim(world, instanceId, actorUuids) : null; + applyPlayerAnimationStartEnergy(world, playerActors, animationId, startContext); + + for (UUID uuid : actorUuids) { + if (world.getEntity(uuid) instanceof EnergyHolder holder) { + holder.onNonAnimationStarted(); + } + } + + if (!playerActors.isEmpty()) { + for (ServerPlayerEntity player : playerActors) { + NonStats.incrementAnimationStarted(player); + if (actorUuids.size() > 2) { + NonStats.incrementMultiActorAnimation(player); + } + + if (actorUuids.size() >= 4) { + NonAdvancementHooks.grantGroupActivity(player); + } + + if (isWaterAnimation(animationId)) { + NonStats.incrementWaterAnimation(player); + } + + if (isAttack && !isDefeatAnimation && player == attackVictim) { + NonStats.incrementAttackAnimationStarted(player); + } else if (!isAttack) { + NonAdvancementHooks.resetTossedAround(player); + } + } + } + + if (isFillBottleAnimation(animationId) && !playerActors.isEmpty()) { + FILL_BOTTLE_STATES.putIfAbsent(instanceId, new NeedsOfNature.FillBottleState(playerActors.getFirst().getUuid(), false)); + } + + if (isPregnancy) { + for (ServerPlayerEntity player : playerActors) { + player.removeStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT)); + } + } + + NonAccessoryShedSystem.ShedState animationAccessoryShedState = NonAccessoryShedSystem.resolveShedStateForAnimation(animationId); + if (!playerActors.isEmpty()) { + for (int actorIndex = 0; actorIndex < actorUuids.size(); actorIndex++) { + if (world.getEntity(actorUuids.get(actorIndex)) instanceof ServerPlayerEntity player) { + NonAccessoryShedSystem.ShedState accessoryShedState = animationAccessoryShedState; + String actorKey = actorIndex < actorKeys.size() ? actorKeys.get(actorIndex) : null; + accessoryShedState = resolveRoleAccessoryShedState(animationId, actorKey, player, accessoryShedState); + if (isPregnancy) { + accessoryShedState = accessoryShedState.merge(NonAccessoryShedSystem.forSlot(NonTrinketsIntegration.getActiveTankSlotId(player))); + } + + if (!accessoryShedState.isEmpty()) { + NonAccessoryShedSystem.addServerShedState(world, player, accessoryShedState); + } + } + } + } + + NonPlayerAttackSystem.onAnimationStarted( + world, instanceId, actorUuids, sessionData == null ? null : sessionData.playerAttack(), startTick, isDefeatAnimation + ); + if (shouldApplyDestroyedSkinAttackDamage(isAttack, startContext, animationId) && attackVictim != null) { + NonDestroyedSkinSystem.applyDestroyedSkinAttackDamage(world, attackVictim); + } + + int startStage = resolveStartStage(stages); + AnimationStageInfo startStageInfo = stageInfoForNumber(stages, startStage); + Integer peakStage = NonPeakStages.getPeakStage(animationId); + if (peakStage != null) { + boolean grantAtStart = startStage == peakStage; + int actualAddedMl = 0; + if (grantAtStart) { + actualAddedMl = applyPeakStageEffects(world, instanceId, actorUuids, actorKeys, animationId, startStageInfo, startStage, startTick); + } + + PEAK_STATES.put( + instanceId, new NeedsOfNature.PeakState(animationId, peakStage, grantAtStart, startStage, hasPlayer, grantAtStart, actualAddedMl) + ); + if (grantAtStart && isAttack && !isManualPeakAnimation(animationId)) { + queueDefeatedTailForInstance(world, instanceId, actorUuids); + } + } + + refreshPregnancyCueRuntime(world, instanceId, startTick, animationId); + if (!playerActors.isEmpty()) { + sendEscapeControlState(world, instanceId, isAttack, isDefeatAnimation, startStageInfo, playerActors); + } + } + ); + AnimationLifecycleEvents.STOP.register((AnimationLifecycleEvents.Stop)event -> { + ServerWorld world = event.world(); + ServerAnimationController.ActiveInstanceSnapshot snapshot = event.snapshot(); + UUID instanceId = snapshot.instanceId(); + Identifier animationId = snapshot.animationId(); + List actorUuids = snapshot.actorUuids(); + List actorKeys = snapshot.actorKeys(); + clearPendingGatherPairsForActors(world, actorUuids); + clearEscapeControlState(world, instanceId, actorUuids); + NonPlayerAttackSystem.onAnimationStopped(world, instanceId); + PEAK_LIQUID_PROGRESS.remove(instanceId); + boolean isAttack = isAttackSnapshot(snapshot); + NeedsOfNature.PeakState peakState = PEAK_STATES.remove(instanceId); + if (peakState != null && peakState.peaked) { + if (isAttack) { + recordAttackOutcomeForMobs(world, actorUuids, "peak"); + } + + if (peakState.hasPlayer) { + calmMobsAfterPeakedPlayerAnimation(world, actorUuids); + } + + if (!isManualPeakAnimation(peakState.animationId) && !isFillBottleAnimation(peakState.animationId)) { + if (peakState.hasPlayer) { + int xpEligibleMl = PEAK_XP_ELIGIBLE_ML.getOrDefault(instanceId, 0); + spawnPeakXp(world, actorUuids, xpEligibleMl); + } + + scheduleReceiverDripOnPeak(world, actorUuids, actorKeys, peakState.animationId, world.getTime()); + } + } + + boolean peaked = peakState != null && peakState.peaked; + finalizeAnimationBreeding(world, instanceId, peaked); + NeedsOfNature.FillBottleState fillState = FILL_BOTTLE_STATES.remove(instanceId); + if (fillState != null) { + handleFillBottleStop(world, fillState, peaked); + } + + finalizePregnancyHatch(world, instanceId, false); + DEFEATED_QUEUED_INSTANCES.remove(instanceId); + PEAK_XP_ELIGIBLE_ML.remove(instanceId); + }); + AnimationLifecycleEvents.STAGE_ADVANCE + .register( + (AnimationLifecycleEvents.StageAdvance)event -> { + ServerWorld world = event.world(); + ServerAnimationController.ActiveInstanceSnapshot snapshot = event.snapshot(); + UUID instanceId = snapshot.instanceId(); + Identifier animationId = snapshot.animationId(); + List actorUuids = snapshot.actorUuids(); + List actorKeys = snapshot.actorKeys(); + long advanceTick = event.advanceTick(); + refreshPregnancyCueRuntime(world, instanceId, advanceTick, animationId); + routePregnancyBirthStage(world, instanceId); + AnimationStageInfo currentStage = event.currentStage(); + syncEscapeControlState(world, instanceId, actorUuids, currentStage); + NeedsOfNature.PeakState peakState = PEAK_STATES.get(instanceId); + if (peakState != null) { + int nextStage = peakState.lastStage; + Integer stageNumber = NonPeakStages.stageNumberFromId(currentStage == null ? null : currentStage.animationId()); + if (stageNumber != null) { + nextStage = stageNumber; + } + + boolean liquidGranted = peakState.liquidGranted; + int actualAddedMl = peakState.actualAddedMl; + if (!liquidGranted && stageNumber != null && stageNumber == peakState.peakStage) { + liquidGranted = true; + actualAddedMl += applyPeakStageEffects(world, instanceId, actorUuids, actorKeys, animationId, currentStage, stageNumber, advanceTick); + ServerAnimationController.ActiveInstanceSnapshot currentSnapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (isAttackSnapshot(currentSnapshot) && peakState.hasPlayer && !isManualPeakAnimation(animationId) && !isFillBottleAnimation(animationId) + ) + { + queueDefeatedTailForInstance(world, instanceId, actorUuids); + } + } else if (liquidGranted && stageNumber != null && stageNumber != peakState.peakStage) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } + + boolean peaked = peakState.peaked; + if (!peaked) { + if (stageNumber != null && stageNumber == peakState.peakStage) { + peaked = true; + } else if (peakState.lastStage == peakState.peakStage) { + peaked = true; + } + } + + PEAK_STATES.put( + instanceId, + new NeedsOfNature.PeakState( + peakState.animationId, peakState.peakStage, peaked, nextStage, peakState.hasPlayer, liquidGranted, actualAddedMl + ) + ); + } + } + ); + } + + private static NonAccessoryShedSystem.ShedState resolveRoleAccessoryShedState( + Identifier animationId, @Nullable String actorKey, ServerPlayerEntity player, NonAccessoryShedSystem.ShedState baseState + ) { + if (player != null && NonTrinketsIntegration.hasMaleRoleAccessory(player)) { + boolean femaleRole = false; + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(animationId); + AnimationDefinitions.ActorConstraint constraint = AnimationDefinitions.resolveActorConstraintForKey(definition, actorKey); + if (constraint != null) { + femaleRole = constraint.requiredTags().contains("gender.female") || constraint.requiredTagsAny().contains("gender.female"); + } + + boolean receivesV = false; + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && actorKey != null && roles.receiverKeys().contains(actorKey)) { + receivesV = roles.injectorRoles().containsValue(NonLiquidRoles.InjectorRole.V); + } + + return !femaleRole && !receivesV ? baseState : baseState.merge(NonAccessoryShedSystem.forSlot("legs/v")); + } else { + return baseState; + } + } + + private static void syncEscapeControlState(ServerWorld world, UUID instanceId, List actorUuids, @Nullable AnimationStageInfo currentStage) { + List playerActors = collectPlayerActors(world, actorUuids); + if (!playerActors.isEmpty()) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + boolean isAttack = isAttackSnapshot(snapshot); + Identifier animationId = snapshot == null ? null : snapshot.animationId(); + boolean isDefeatAnimation = hasAnimationTagWithPrefix(animationId, "defeat."); + sendEscapeControlState(world, instanceId, isAttack, isDefeatAnimation, currentStage, playerActors); + } + } + + private static void sendEscapeControlState( + ServerWorld world, + UUID instanceId, + boolean isAttack, + boolean isDefeatAnimation, + @Nullable AnimationStageInfo currentStage, + List playerActors + ) { + if (instanceId != null && playerActors != null && !playerActors.isEmpty()) { + boolean escapable = isAttack && isEscapableStage(currentStage); + if (!isAttack || !NonPlayerAttackSystem.syncEscapeControl(world, instanceId, escapable, isDefeatAnimation)) { + AttackStateS2CPayload.EscapeProfile profile = AttackStateS2CPayload.EscapeProfile.NONE; + if (isAttack) { + profile = isDefeatAnimation ? AttackStateS2CPayload.EscapeProfile.DEFEATED : AttackStateS2CPayload.EscapeProfile.NORMAL; + } + + AttackStateS2CPayload payload = new AttackStateS2CPayload(instanceId, isAttack, escapable, profile); + + for (ServerPlayerEntity player : playerActors) { + ServerPlayNetworking.send(player, payload); + } + } + } + } + + private static void clearEscapeControlState(ServerWorld world, UUID instanceId, List actorUuids) { + if (world != null && instanceId != null) { + List playerActors = collectPlayerActors(world, actorUuids); + if (!playerActors.isEmpty()) { + AttackStateS2CPayload payload = new AttackStateS2CPayload(instanceId, false, false, AttackStateS2CPayload.EscapeProfile.NONE); + + for (ServerPlayerEntity player : playerActors) { + ServerPlayNetworking.send(player, payload); + } + } + } + } + + private static boolean isEscapableStage(@Nullable AnimationStageInfo stage) { + return stage != null && stage.animationId() != null ? AnimationGameplayMetadata.escapable(stage.animationId()) : true; + } + + private static void spawnPeakXp(ServerWorld world, List actorUuids, int xpEligibleMl) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (xpEligibleMl > 0) { + double xpPerMl = getConfig().getPeakXpPerMl(); + if (!(xpPerMl <= 0.0)) { + int xpAmount = Math.max(1, (int)Math.round(xpEligibleMl * xpPerMl)); + xpAmount = Math.min(20, xpAmount); + + for (UUID uuid : actorUuids) { + Entity entity = world.getEntity(uuid); + if (entity != null) { + Vec3d pos = new Vec3d(entity.getX(), entity.getBodyY(0.5), entity.getZ()); + ExperienceOrbEntity.spawn(world, pos, xpAmount); + } + } + } + } + } + } + + private static void applyPlayerAnimationStartEnergy( + ServerWorld world, List playerActors, Identifier animationId, @Nullable AnimationStartContext startContext + ) { + if (world != null && playerActors != null && !playerActors.isEmpty()) { + if (shouldApplyPlayerAnimationStartEnergy(animationId, startContext)) { + for (ServerPlayerEntity player : playerActors) { + if (player instanceof EnergyHolder holder) { + int current = Math.max(0, holder.getEnergy()); + holder.setEnergy(Math.min(holder.getMaxEnergy(), current + 20)); + updateEnergizedEffect(player, holder); + } + } + } + } + } + + private static boolean shouldApplyPlayerAnimationStartEnergy(Identifier animationId, @Nullable AnimationStartContext startContext) { + if (animationId == null) { + return false; + } else if (startContext != null && startContext.isJoinReplacement()) { + return false; + } else if (isManualPeakAnimation(animationId)) { + return false; + } else { + return isBirthAnimation(animationId) ? false : !isFillBottleAnimation(animationId); + } + } + + private static void applyEnergyOnPeak(ServerWorld world, List actorUuids, boolean manualPeak) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + for (UUID uuid : actorUuids) { + Entity entity = world.getEntity(uuid); + if (entity instanceof EnergyHolder holder) { + if (entity instanceof ServerPlayerEntity) { + int current = Math.max(0, holder.getEnergy()); + int next = manualPeak ? (int)Math.floor(current * 0.6666666666666666) : current / 2; + holder.setEnergy(next); + } else { + holder.setEnergy(0); + float currentMult = holder.getEnergyGainMultiplier(); + if (Float.isFinite(currentMult) && currentMult > 0.0F) { + holder.setEnergyGainMultiplier(Math.min(6.0F, currentMult + 0.05F)); + } + } + } + } + } + } + + private static int applyPeakStageEffects( + ServerWorld world, + UUID instanceId, + List actorUuids, + List actorKeys, + Identifier animationId, + @Nullable AnimationStageInfo currentStage, + @Nullable Integer stageNumber, + long stageStartTick + ) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + boolean manualPeak = isManualPeakAnimation(animationId); + boolean fillBottle = isFillBottleAnimation(animationId); + int actualAddedMl = 0; + if (!fillBottle) { + applyEnergyOnPeak(world, actorUuids, manualPeak); + } + + NeedsOfNature.PeakLiquidPlan liquidPlan = new NeedsOfNature.PeakLiquidPlan(Map.of(), Map.of(), Set.of()); + if (!manualPeak && !fillBottle) { + liquidPlan = preparePeakLiquidPlan(world, actorUuids, actorKeys, animationId); + actualAddedMl = startPeakLiquidProgression(world, instanceId, animationId, liquidPlan, currentStage, stageNumber, stageStartTick); + fillHorseLiquidCollectorsOnPeak(world, actorUuids, actorKeys, animationId); + } + + trackPeakStats(world, actorUuids, manualPeak); + if (!manualPeak && !fillBottle) { + maybeStartPregnancyOnPeak(world, actorUuids, actorKeys, animationId, liquidPlan.accessoryPregnancyDonors()); + } + + applyInjectionAccessoryDurabilityCosts(world, actorUuids, actorKeys, animationId); + NonMessSystem.incrementMessOnPeak(world, actorUuids, actorKeys, animationId, liquidPlan.suppliedInjectorRoles()); + scheduleMInjectorRewardsOnPeak(world, actorUuids, actorKeys, animationId, liquidPlan.suppliedInjectorRoles()); + return actualAddedMl; + } else { + return 0; + } + } + + private static void applyInjectionAccessoryDurabilityCosts(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId) { + if (world != null && actorUuids != null && !actorUuids.isEmpty() && animationId != null) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && !roles.injectorRoles().isEmpty()) { + boolean hasV = roles.injectorRoles().containsValue(NonLiquidRoles.InjectorRole.V); + boolean hasA = roles.injectorRoles().containsValue(NonLiquidRoles.InjectorRole.A); + if (hasV || hasA) { + for (ServerPlayerEntity player : collectPlayerReceiverActors(world, actorUuids, actorKeys, animationId)) { + if (player != null && player.isAlive() && !player.isRemoved()) { + if (hasV) { + NonTrinketsIntegration.applyInjectionDurabilityCost(world, player, NonLiquidRoles.InjectorRole.V); + } + + if (hasA) { + NonTrinketsIntegration.applyInjectionDurabilityCost(world, player, NonLiquidRoles.InjectorRole.A); + } + } + } + } + } + } + } + + private static void scheduleMInjectorRewardsOnPeak( + ServerWorld world, List actorUuids, List actorKeys, Identifier animationId, Set suppliedInjectorRoles + ) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (suppliedInjectorRoles != null && suppliedInjectorRoles.contains(NonLiquidRoles.InjectorRole.M)) { + for (ServerPlayerEntity player : collectPlayerReceiverActors(world, actorUuids, actorKeys, animationId)) { + if (player != null && player.isAlive() && !player.isRemoved()) { + PENDING_M_INJECTOR_REWARDS.put(player.getUuid(), new NeedsOfNature.PendingMInjectorReward(world.getRegistryKey(), -1L)); + NonStats.addMInjectorPeaksMl(player, 8); + } + } + } + } + } + + @Nullable + private static AnimationStageInfo stageInfoForNumber(@Nullable List stages, int stageNumber) { + if (stages == null || stages.isEmpty()) { + return null; + } + + if (stageNumber <= 0) { + return null; + } + + int index = stageNumber - 1; + return index >= 0 && index < stages.size() ? stages.get(index) : null; + } + + private static NeedsOfNature.PeakLiquidPlan preparePeakLiquidPlan(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId) { + if (world != null && actorUuids != null && actorKeys != null && animationId != null && !actorUuids.isEmpty() && actorUuids.size() == actorKeys.size()) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && !roles.receiverKeys().isEmpty()) { + Set suppliedInjectorRoles = EnumSet.noneOf(NonLiquidRoles.InjectorRole.class); + double liquidGainMultiplier = resolveLiquidGainMultiplier(animationId); + + for (int i = 0; i < actorUuids.size(); i++) { + NonLiquidRoles.InjectorRole role = roles.injectorRoles().get(actorKeys.get(i)); + if (role != null && role != NonLiquidRoles.InjectorRole.NONE) { + Entity entity = world.getEntity(actorUuids.get(i)); + if (entity != null && !(entity instanceof ServerPlayerEntity player && NonTrinketsIntegration.hasMaleRoleAccessory(player))) { + Identifier typeId = Registries.ENTITY_TYPE.getId(entity.getType()); + int suppliedMl = applyLiquidGainMultiplier(resolveEntityLiquidGainMl(typeId), liquidGainMultiplier); + if (suppliedMl > 0) { + suppliedInjectorRoles.add(role); + } + } + } + } + + Map receivers = new LinkedHashMap<>(); + Map gains = new LinkedHashMap<>(); + + for (int i = 0; i < actorUuids.size(); i++) { + if (roles.receiverKeys().contains(actorKeys.get(i)) + && world.getEntity(actorUuids.get(i)) instanceof ServerPlayerEntity player + && player.isAlive() + && !player.isRemoved()) { + receivers.put(player.getUuid(), player); + NeedsOfNature.LiquidGainProfile normalGain = resolveLiquidGainProfile( + world, actorUuids, actorKeys, animationId, NonGenderSystem.getLiquidTankType(player) + ); + normalGain = applyApiLiquidGainModifier(player, applyAccessoryLiquidGainMultiplier(player, normalGain)); + if (normalGain.amount() > 0) { + gains.put(player.getUuid(), normalGain); + } + } + } + + Map> accessoryDonors = new LinkedHashMap<>(); + + for (int i = 0; i < actorUuids.size(); i++) { + NonLiquidRoles.InjectorRole role = roles.injectorRoles().get(actorKeys.get(i)); + if ((role == NonLiquidRoles.InjectorRole.V || role == NonLiquidRoles.InjectorRole.A || role == NonLiquidRoles.InjectorRole.M) + && world.getEntity(actorUuids.get(i)) instanceof ServerPlayerEntity injector) { + NonTrinketsIntegration.StoredInjectorLiquid stored = NonTrinketsIntegration.peekStoredInjectorLiquid(injector); + if (stored != null && stored.entityTypeId() != null && stored.amountMl() > 0) { + List compatibleReceivers = role == NonLiquidRoles.InjectorRole.M + ? List.copyOf(receivers.values()) + : receivers.values() + .stream() + .filter(receiverx -> isLiquidInjectorAccepted(role, NonGenderSystem.getLiquidTankType(receiverx))) + .toList(); + if (!compatibleReceivers.isEmpty()) { + NonTrinketsIntegration.StoredInjectorLiquid consumed = NonTrinketsIntegration.consumeStoredInjectorLiquid(injector); + if (consumed != null && consumed.entityTypeId() != null && consumed.amountMl() > 0) { + suppliedInjectorRoles.add(role); + if (role != NonLiquidRoles.InjectorRole.M) { + int baseShare = consumed.amountMl() / compatibleReceivers.size(); + int remainder = consumed.amountMl() % compatibleReceivers.size(); + NeedsOfNature.PregnancyDonor donor = role == NonLiquidRoles.InjectorRole.V + ? new NeedsOfNature.PregnancyDonor(consumed.entityTypeId(), null) + : null; + + for (int receiverIndex = 0; receiverIndex < compatibleReceivers.size(); receiverIndex++) { + ServerPlayerEntity receiver = compatibleReceivers.get(receiverIndex); + int share = baseShare + (receiverIndex < remainder ? 1 : 0); + if (share > 0) { + NeedsOfNature.LiquidGainProfile fixedGain = new NeedsOfNature.LiquidGainProfile( + share, + 0, + LiquidHolder.LiquidComposition.ENTITY, + consumed.entityTypeId(), + Map.of(consumed.entityTypeId(), share), + null, + donor == null ? List.of() : List.of(donor) + ); + gains.merge(receiver.getUuid(), fixedGain, NeedsOfNature::mergeLiquidGainProfiles); + if (donor != null) { + accessoryDonors.computeIfAbsent(receiver.getUuid(), ignored -> new ArrayList<>()).add(donor); + } + } + } + } + } + } + } + } + } + + return new NeedsOfNature.PeakLiquidPlan(gains, accessoryDonors, suppliedInjectorRoles); + } else { + return new NeedsOfNature.PeakLiquidPlan(Map.of(), Map.of(), Set.of()); + } + } else { + return new NeedsOfNature.PeakLiquidPlan(Map.of(), Map.of(), Set.of()); + } + } + + private static NeedsOfNature.LiquidGainProfile mergeLiquidGainProfiles(NeedsOfNature.LiquidGainProfile first, NeedsOfNature.LiquidGainProfile second) { + if (first == null || first.amount() <= 0) { + return second; + } + + if (second != null && second.amount() > 0) { + Map entityAmounts = new LinkedHashMap<>(); + first.entityAmounts().forEach((id, amount) -> { + if (id != null && amount != null && amount > 0) { + entityAmounts.merge(id, amount, Integer::sum); + } + }); + second.entityAmounts().forEach((id, amount) -> { + if (id != null && amount != null && amount > 0) { + entityAmounts.merge(id, amount, Integer::sum); + } + }); + Identifier entityTypeId = entityAmounts.size() == 1 ? entityAmounts.keySet().iterator().next() : null; + LiquidHolder.LiquidComposition composition = entityTypeId == null ? LiquidHolder.LiquidComposition.MIXED : LiquidHolder.LiquidComposition.ENTITY; + NeedsOfNature.PregnancyVariantData variantData = null; + if (entityTypeId != null) { + if (entityTypeId.equals(second.entityTypeId()) && second.donorVariantData() != null) { + variantData = second.donorVariantData(); + } else if (entityTypeId.equals(first.entityTypeId())) { + variantData = first.donorVariantData(); + } + } + + List recentDonors = new ArrayList<>(first.recentDonors()); + recentDonors.addAll(second.recentDonors()); + return new NeedsOfNature.LiquidGainProfile( + first.amount() + second.amount(), + first.xpEligibleAmount() + second.xpEligibleAmount(), + composition, + entityTypeId, + Map.copyOf(entityAmounts), + variantData, + recentDonors + ); + } else { + return first; + } + } + + private static int startPeakLiquidProgression( + ServerWorld world, + UUID instanceId, + Identifier animationId, + NeedsOfNature.PeakLiquidPlan plan, + @Nullable AnimationStageInfo currentStage, + @Nullable Integer stageNumber, + long stageStartTick + ) { + if (world != null && instanceId != null && animationId != null) { + PEAK_XP_ELIGIBLE_ML.remove(instanceId); + int plannedAmount = plan == null ? 0 : plan.maxAmount(); + if (plannedAmount <= 0) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + return 0; + } + + Identifier stageClipId = resolvePeakStageClipId(animationId, currentStage, stageNumber); + List cueOffsets = resolveReactiveImpactCueOffsets(stageClipId); + double cycleTicks = resolveReactiveImpactCycleTicks(currentStage, stageClipId); + cueOffsets = resolveWarpedReactiveImpactCueOffsets(currentStage, cycleTicks, cueOffsets); + double stageSpeed = resolveStageSpeed(currentStage); + if (!cueOffsets.isEmpty() && !(cycleTicks <= 0.0)) { + long stageDurationTicks = resolveLoopAdvanceIntervalTicks(animationId, stageNumber, currentStage); + if (stageDurationTicks < 0L) { + PEAK_LIQUID_PROGRESS.put( + instanceId, + NeedsOfNature.PeakLiquidProgressState.infinite(world.getRegistryKey(), plan, cycleTicks, List.copyOf(cueOffsets), stageStartTick, stageSpeed) + ); + return 0; + } else { + NeedsOfNature.FinitePulseSchedule schedule = buildFinitePulseSchedule( + stageStartTick, stageDurationTicks, cycleTicks, cueOffsets, plannedAmount, stageSpeed + ); + if (schedule != null && !schedule.ticks().isEmpty()) { + PEAK_LIQUID_PROGRESS.put( + instanceId, NeedsOfNature.PeakLiquidProgressState.finite(world.getRegistryKey(), plan, schedule.ticks(), schedule.amounts()) + ); + return 0; + } else { + PEAK_LIQUID_PROGRESS.remove(instanceId); + NeedsOfNature.LiquidPulseResult result = grantLiquidPulseToReceivers(world, plan, 0, plannedAmount, new HashSet<>()); + recordPeakLiquidPulse(instanceId, result); + return result.addedMl(); + } + } + } else { + PEAK_LIQUID_PROGRESS.remove(instanceId); + NeedsOfNature.LiquidPulseResult result = grantLiquidPulseToReceivers(world, plan, 0, plannedAmount, new HashSet<>()); + recordPeakLiquidPulse(instanceId, result); + return result.addedMl(); + } + } else { + return 0; + } + } + + private static Identifier resolvePeakStageClipId(Identifier animationId, @Nullable AnimationStageInfo currentStage, @Nullable Integer stageNumber) { + if (currentStage != null) { + Identifier playback = currentStage.effectiveAnimationId(); + if (playback != null) { + return playback; + } + + if (currentStage.animationId() != null) { + return currentStage.animationId(); + } + } + + return resolveStageId(animationId, stageNumber); + } + + private static List resolveReactiveImpactCueOffsets(@Nullable Identifier stageClipId) { + AnimationCueIndex.CueProfile profile = AnimationCueIndex.getProfile(stageClipId); + if (profile == null) { + return List.of(); + } + + List cueTicks = profile.cueTicks("reactiveimpact"); + if (cueTicks.isEmpty()) { + return List.of(); + } + + List out = new ArrayList<>(); + + for (Integer tick : cueTicks) { + if (tick != null && tick >= 0) { + out.add(tick); + } + } + + if (out.isEmpty()) { + return List.of(); + } + + out.sort(Integer::compareTo); + return List.copyOf(out); + } + + private static double resolveReactiveImpactCycleTicks(@Nullable AnimationStageInfo currentStage, @Nullable Identifier stageClipId) { + AnimationCueIndex.CueProfile profile = AnimationCueIndex.getProfile(stageClipId); + if (profile != null && profile.cycleTicks() != null) { + long cueCycleTicks = Math.max(0L, profile.cycleTicks().intValue()); + if (cueCycleTicks > 0L) { + return cueCycleTicks; + } + } + + if (currentStage != null) { + double ticks = exactCycleTicks(currentStage.animationId(), currentStage); + if (ticks > 0.0) { + return ticks; + } + } + + return 0.0; + } + + private static List resolveWarpedReactiveImpactCueOffsets(@Nullable AnimationStageInfo currentStage, double cycleTicks, List cueOffsets) { + if (currentStage != null && cueOffsets != null && !cueOffsets.isEmpty()) { + if (currentStage.loop() && !(cycleTicks <= 0.0)) { + double offsetTicks = StageTimeWarp.offsetSecondsToTicks(currentStage.cycleMidpointOffsetSeconds()); + if (!StageTimeWarp.hasWarp(cycleTicks, offsetTicks)) { + return cueOffsets; + } + + Set warped = new TreeSet<>(); + + for (Integer cueOffset : cueOffsets) { + if (cueOffset != null && cueOffset >= 0) { + double shifted = StageTimeWarp.authoredToWarpedCycleTicks(cueOffset.intValue(), cycleTicks, offsetTicks); + warped.add(Math.max(0, (int)Math.round(shifted))); + } + } + + return warped.isEmpty() ? List.of() : List.copyOf(warped); + } else { + return cueOffsets; + } + } else { + return cueOffsets; + } + } + + private static double resolveStageSpeed(@Nullable AnimationStageInfo currentStage) { + if (currentStage == null) { + return 1.0; + } + + double speed = currentStage.speed(); + return Double.isFinite(speed) && !(speed <= 0.0) ? clampAnimationSpeed(speed) : 1.0; + } + + private static double clampAnimationSpeed(double speed) { + return Double.isFinite(speed) && !(speed <= 0.0) ? Math.max(0.1, Math.min(speed, 4.0)) : 1.0; + } + + private static long elapsedWorldTicksFromAnimationTicks(double animationTicks, double speed) { + if (Double.isFinite(animationTicks) && !(animationTicks <= 0.0)) { + double safeSpeed = Double.isFinite(speed) && !(speed <= 0.0) ? speed : 1.0; + double elapsed = animationTicks / safeSpeed; + return Double.isFinite(elapsed) && !(elapsed <= 0.0) ? Math.max(0L, (long)Math.ceil(elapsed - 1.0E-9)) : 0L; + } else { + return 0L; + } + } + + @Nullable + private static NeedsOfNature.FinitePulseSchedule buildFinitePulseSchedule( + long stageStartTick, long stageDurationTicks, double cycleTicks, List cueOffsets, int totalAmountPerReceiver, double stageSpeed + ) { + if (stageDurationTicks > 0L && !(cycleTicks <= 0.0) && totalAmountPerReceiver > 0) { + if (cueOffsets != null && !cueOffsets.isEmpty()) { + long stageEndTick = stageStartTick + stageDurationTicks; + List pulseTicks = new ArrayList<>(); + List sortedOffsets = new ArrayList<>(); + + for (Integer rawOffset : cueOffsets) { + if (rawOffset != null) { + sortedOffsets.add(Math.max(0, rawOffset)); + } + } + + if (sortedOffsets.isEmpty()) { + return null; + } + + sortedOffsets.sort(Integer::compareTo); + int minOffset = sortedOffsets.getFirst(); + + for (long cycleIndex = 0L; cycleIndex < 100000L; cycleIndex++) { + double firstAnimTick = cycleIndex * cycleTicks + minOffset; + long firstPulseTick = stageStartTick + elapsedWorldTicksFromAnimationTicks(firstAnimTick, stageSpeed); + if (firstPulseTick >= stageEndTick) { + break; + } + + for (int offset : sortedOffsets) { + double cueAnimTick = cycleIndex * cycleTicks + offset; + long pulseTick = stageStartTick + elapsedWorldTicksFromAnimationTicks(cueAnimTick, stageSpeed); + if (pulseTick < stageEndTick) { + pulseTicks.add(pulseTick); + } + } + } + + if (pulseTicks.isEmpty()) { + return null; + } + + pulseTicks.sort(Long::compareTo); + int pulseCount = pulseTicks.size(); + int base = totalAmountPerReceiver / pulseCount; + int remainder = totalAmountPerReceiver % pulseCount; + List scheduledTicks = new ArrayList<>(pulseCount); + List scheduledAmounts = new ArrayList<>(pulseCount); + + for (int i = 0; i < pulseCount; i++) { + int amount = base + (i < remainder ? 1 : 0); + if (amount > 0) { + scheduledTicks.add(pulseTicks.get(i)); + scheduledAmounts.add(amount); + } + } + + return scheduledTicks.isEmpty() ? null : new NeedsOfNature.FinitePulseSchedule(List.copyOf(scheduledTicks), List.copyOf(scheduledAmounts)); + } else { + return null; + } + } else { + return null; + } + } + + private static NeedsOfNature.LiquidPulseResult grantLiquidPulseToReceivers( + ServerWorld world, NeedsOfNature.PeakLiquidPlan plan, int previousBaseAmount, int nextBaseAmount, Set donorHistoryRecordedReceivers + ) { + if (world != null && plan != null && !plan.gainsByReceiver().isEmpty()) { + int planAmount = plan.maxAmount(); + int previous = Math.max(0, Math.min(planAmount, previousBaseAmount)); + int next = Math.max(previous, Math.min(planAmount, nextBaseAmount)); + if (planAmount > 0 && next > previous) { + int totalAdded = 0; + int totalXpEligibleAdded = 0; + + for (Entry entry : plan.gainsByReceiver().entrySet()) { + if (world.getEntity(entry.getKey()) instanceof ServerPlayerEntity player + && player.isAlive() + && !player.isRemoved() + && player instanceof LiquidHolder holder) { + NeedsOfNature.LiquidGainProfile receiverGain = entry.getValue(); + if (receiverGain != null && receiverGain.amount() > 0) { + int previousTarget = (int)Math.round((double)receiverGain.amount() * previous / planAmount); + int nextTarget = (int)Math.round((double)receiverGain.amount() * next / planAmount); + int pulseAmount = Math.max(0, nextTarget - previousTarget); + if (pulseAmount > 0) { + NeedsOfNature.LiquidGainProfile pulseGain = scaleLiquidGainProfile(receiverGain, (double)pulseAmount / receiverGain.amount()); + int added = addLiquidWithComposition(holder, pulseGain); + if (added > 0) { + if (!pulseGain.recentDonors().isEmpty() && donorHistoryRecordedReceivers.add(player.getUuid())) { + recordRecentLiquidDonors(holder, pulseGain.recentDonors()); + } + + totalAdded += added; + int previousXpTarget = (int)Math.round((double)receiverGain.xpEligibleAmount() * previous / planAmount); + int nextXpTarget = (int)Math.round((double)receiverGain.xpEligibleAmount() * next / planAmount); + int pulseXpEligible = Math.max(0, nextXpTarget - previousXpTarget); + if (pulseXpEligible > 0) { + totalXpEligibleAdded += Math.min(added, (int)Math.round((double)pulseXpEligible * added / pulseAmount)); + } + + Map scaledAmounts = scaleEntityAmounts(pulseGain.entityAmounts(), added); + NonStats.addLiquidGained(player, added, scaledAmounts); + trackFieldResearch(world, player, scaledAmounts); + syncLiquidState(player); + } + } + } + } + } + + return new NeedsOfNature.LiquidPulseResult(totalAdded, totalXpEligibleAdded); + } else { + return NeedsOfNature.LiquidPulseResult.EMPTY; + } + } else { + return NeedsOfNature.LiquidPulseResult.EMPTY; + } + } + + private static void recordRecentLiquidDonors(LiquidHolder holder, List donors) { + if (holder != null && donors != null && !donors.isEmpty()) { + for (NeedsOfNature.PregnancyDonor donor : donors) { + if (donor != null) { + NonPregnancyConceptionSystem.recordRecentDonor(holder, donor.entityTypeId(), donor.variantData()); + } + } + } + } + + private static void recordPeakLiquidPulse(UUID instanceId, NeedsOfNature.LiquidPulseResult result) { + if (instanceId != null && result != null && result.xpEligibleAddedMl() > 0) { + PEAK_XP_ELIGIBLE_ML.merge(instanceId, result.xpEligibleAddedMl(), Integer::sum); + } + } + + private static void addActualAddedMlToPeakState(UUID instanceId, int addedMl) { + if (instanceId != null && addedMl > 0) { + NeedsOfNature.PeakState state = PEAK_STATES.get(instanceId); + if (state != null) { + PEAK_STATES.put( + instanceId, + new NeedsOfNature.PeakState( + state.animationId(), + state.peakStage(), + state.peaked(), + state.lastStage(), + state.hasPlayer(), + state.liquidGranted(), + state.actualAddedMl() + addedMl + ) + ); + } + } + } + + private static void trackPeakStats(ServerWorld world, List actorUuids, boolean manualPeak) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + List players = collectPlayerActors(world, actorUuids); + if (!players.isEmpty()) { + Set nonPlayerEntityTypes = collectNonPlayerEntityTypes(world, actorUuids); + + for (ServerPlayerEntity player : players) { + NonStats.incrementPeakedAnimations(player, nonPlayerEntityTypes); + NonAdvancementHooks.grantNaturalCuriosity(player); + if (manualPeak) { + NonStats.incrementManualPeak(player); + } + } + } + } + } + + static List collectPlayerActors(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + List players = new ArrayList<>(); + + for (UUID uuid : actorUuids) { + if (world.getEntity(uuid) instanceof ServerPlayerEntity player) { + players.add(player); + } + } + + return players; + } else { + return List.of(); + } + } + + static List collectPlayerReceiverActors(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId) { + if (world != null && actorUuids != null && actorKeys != null && animationId != null) { + if (!actorUuids.isEmpty() && actorUuids.size() == actorKeys.size()) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && !roles.receiverKeys().isEmpty()) { + List players = new ArrayList<>(); + + for (int i = 0; i < actorUuids.size(); i++) { + if (roles.receiverKeys().contains(actorKeys.get(i)) && world.getEntity(actorUuids.get(i)) instanceof ServerPlayerEntity player) { + players.add(player); + } + } + + return players; + } else { + return List.of(); + } + } else { + return List.of(); + } + } else { + return List.of(); + } + } + + private static List collectPlayerActors(List actors) { + if (actors != null && !actors.isEmpty()) { + List players = new ArrayList<>(); + + for (Entity actor : actors) { + if (actor instanceof ServerPlayerEntity player) { + players.add(player); + } + } + + return players; + } else { + return List.of(); + } + } + + private static Set collectNonPlayerEntityTypes(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + Set out = new LinkedHashSet<>(); + + for (UUID uuid : actorUuids) { + Entity entity = world.getEntity(uuid); + if (entity != null && !(entity instanceof ServerPlayerEntity)) { + Identifier entityId = Registries.ENTITY_TYPE.getId(entity.getType()); + out.add(entityId); + } + } + + return out; + } else { + return Set.of(); + } + } + + private static void handleFillBottleStop(ServerWorld world, NeedsOfNature.FillBottleState state, boolean peaked) { + if (world != null && state != null) { + ServerPlayerEntity player = findPlayerOnWorldServer(world, state.playerUuid()); + if (player != null && player.getEntityWorld() == world) { + if (player instanceof LiquidHolder holder) { + if (!peaked) { + if (state.reservedBottle()) { + NonItemSystem.refundGlassBottle(player); + } + } else { + int stored = holder.getLiquidStored(); + updateFilledEffect(player, holder); + int requiredMl = getFillBottleRequiredMl(holder); + if (stored >= requiredMl && hasFilledStageOneOrHigher(player)) { + ItemStack resultBottle = NonItemSystem.createBottleStackForTank(holder); + holder.setLiquidStored(stored - requiredMl); + syncLiquidState(player); + NonItemSystem.giveItemOrDrop(player, resultBottle); + NonStats.incrementFillBottle(player); + NonAdvancementHooks.checkCollectorsHabit(player); + } else { + if (state.reservedBottle()) { + NonItemSystem.refundGlassBottle(player); + } + } + } + } else { + if (state.reservedBottle()) { + NonItemSystem.refundGlassBottle(player); + } + } + } + } + } + + private static int getFillBottleRequiredMl(LiquidHolder holder) { + return holder == null ? Integer.MAX_VALUE : getFilledStageOneRequiredMl(holder.getLiquidCapacity()); + } + + private static int getFilledStageOneRequiredMl(int capacity) { + return capacity <= 0 ? Integer.MAX_VALUE : Math.max(1, (int)Math.ceil(capacity * 0.3F)); + } + + private static boolean hasFilledStageOneOrHigher(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + RegistryEntry entry = Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FILLED); + StatusEffectInstance effect = player.getStatusEffect(entry); + return effect != null && effect.getAmplifier() >= 0; + } + + private static int addLiquidWithComposition(LiquidHolder holder, NeedsOfNature.LiquidGainProfile gain) { + if (holder != null && gain != null && gain.amount() > 0) { + int stored = holder.getLiquidStored(); + int capacity = holder.getLiquidCapacity(); + int room = Math.max(0, capacity - stored); + int added = Math.min(room, gain.amount()); + if (added == 0) { + return 0; + } + + if (stored <= 0) { + if (gain.composition() == LiquidHolder.LiquidComposition.ENTITY && gain.entityTypeId() != null) { + holder.setLiquidCompositionEntity(gain.entityTypeId()); + holder.setLiquidDonorVariantData(gain.donorVariantData()); + } else { + holder.setLiquidCompositionMixed(); + } + } else { + LiquidHolder.LiquidComposition existing = holder.getLiquidComposition(); + if (existing == LiquidHolder.LiquidComposition.ENTITY) { + Identifier existingType = holder.getLiquidEntityTypeId(); + boolean sameEntity = gain.composition() == LiquidHolder.LiquidComposition.ENTITY + && gain.entityTypeId() != null + && gain.entityTypeId().equals(existingType); + if (!sameEntity) { + holder.setLiquidCompositionMixed(); + } else { + holder.setLiquidDonorVariantData(gain.donorVariantData()); + } + } else if (existing != LiquidHolder.LiquidComposition.MIXED) { + holder.setLiquidCompositionMixed(); + } + } + + holder.setLiquidStored(stored + added); + return added; + } else { + return 0; + } + } + + private static Map scaleEntityAmounts(Map source, int targetTotal) { + if (source != null && !source.isEmpty() && targetTotal > 0) { + int sourceTotal = 0; + + for (Integer value : source.values()) { + if (value != null && value > 0) { + sourceTotal += value; + } + } + + if (sourceTotal <= 0) { + return Map.of(); + } + + if (targetTotal >= sourceTotal) { + return Map.copyOf(source); + } + + Map scaled = new LinkedHashMap<>(); + List> remainders = new ArrayList<>(); + int assigned = 0; + + for (Entry entry : source.entrySet()) { + if (entry.getKey() != null) { + int amount = entry.getValue() == null ? 0 : entry.getValue(); + if (amount > 0) { + double exact = (double)amount * targetTotal / sourceTotal; + int floor = (int)Math.floor(exact); + if (floor > 0) { + scaled.put(entry.getKey(), floor); + assigned += floor; + } + + remainders.add(Map.entry(entry.getKey(), exact - floor)); + } + } + } + + int remaining = targetTotal - assigned; + if (remaining <= 0) { + return Map.copyOf(scaled); + } + + remainders.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); + + for (int i = 0; i < remainders.size() && remaining > 0; i++) { + Identifier entityId = remainders.get(i).getKey(); + scaled.merge(entityId, 1, Integer::sum); + remaining--; + } + + return Map.copyOf(scaled); + } else { + return Map.of(); + } + } + + public static NonConfig getConfig() { + NonConfig remote = REMOTE_EFFECTIVE_CONFIG; + return remote == null ? CONFIG : remote; + } + + public static NonConfig getLocalConfig() { + return CONFIG; + } + + public static synchronized void applyRemoteHostRules(HostConfigSyncData rules) { + REMOTE_HOST_RULES = rules; + rebuildRemoteEffectiveConfig(); + } + + public static synchronized void clearRemoteHostRules() { + REMOTE_HOST_RULES = null; + REMOTE_EFFECTIVE_CONFIG = null; + } + + static synchronized void onConfigSaved(NonConfig config) { + if (config == CONFIG && REMOTE_HOST_RULES != null) { + rebuildRemoteEffectiveConfig(); + } + } + + private static void rebuildRemoteEffectiveConfig() { + if (CONFIG != null && REMOTE_HOST_RULES != null) { + NonConfig effective = CONFIG.copy(); + REMOTE_HOST_RULES.applyTo(effective); + REMOTE_EFFECTIVE_CONFIG = effective; + } else { + REMOTE_EFFECTIVE_CONFIG = null; + } + } + + private static void clearServerRuntimeState() { + SCAN_BUDGETS.clear(); + PENDING_POST_DEATH_STATE_SYNCS.clear(); + PEAK_STATES.clear(); + PENDING_GATHER_PAIRS.clear(); + NON_PENDING_GATHER_ACTORS.clear(); + DEFEATED_QUEUED_INSTANCES.clear(); + LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.clear(); + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.clear(); + PENDING_ATTACK_ESCAPE_RESOLUTIONS.clear(); + PEAK_LIQUID_PROGRESS.clear(); + PEAK_XP_ELIGIBLE_ML.clear(); + FILL_BOTTLE_STATES.clear(); + BREEDING_PENDING_BY_PAIR.clear(); + BREEDING_PENDING_BY_REQUEST.clear(); + BREEDING_ACTIVE_BY_INSTANCE.clear(); + BREEDING_ACTIVE_INSTANCE_BY_PAIR.clear(); + PREGNANCY_HATCH_BY_INSTANCE.clear(); + PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.clear(); + PREGNANCY_QUEUED_PLAYERS.clear(); + PREGNANCY_START_RETRY_TICKS.clear(); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.clear(); + REFLECTION_FAILURE_LOGS.clear(); + RECEIVER_DRIP.clear(); + PENDING_M_INJECTOR_REWARDS.clear(); + NonGatherSystem.clearRuntimeState(); + NonLiquidSystem.clearRuntimeState(); + NonMessSystem.clearRuntimeState(); + NonAttackSystem.clearRuntimeState(); + NonPlayerAttackSystem.clearRuntimeState(); + NonDestroyedSkinSystem.clearRuntimeState(); + NonAdvancementHooks.clearRuntimeState(); + NonTrinketsIntegration.clearRuntimeState(); + NonAccessoryShedSystem.clearAll(); + NonStatusEffects.clearRuntimeState(); + } + + public static boolean canMobAttackPlayer(ServerPlayerEntity player) { + return NonAttackSystem.canMobAttackPlayer(player); + } + + public static boolean shouldBypassVanillaPvpForDefeatedFinish(ServerPlayerEntity victim, PlayerEntity attacker) { + return NonPlayerAttackSystem.shouldBypassVanillaPvpCheck(victim, attacker); + } + + public static AnimationStartContext buildModeContext(boolean attack) { + return AnimationStartContext.empty().withSessionData(attack ? NonAnimationSessionData.attack() : NonAnimationSessionData.voluntary()); + } + + @Nullable + private static NonAnimationSessionData getNonSessionData(@Nullable AnimationStartContext context) { + return context != null && context.sessionData() instanceof NonAnimationSessionData data ? data : null; + } + + private static boolean isAttackSnapshot(@Nullable ServerAnimationController.ActiveInstanceSnapshot snapshot) { + NonAnimationSessionData data = snapshot == null ? null : getNonSessionData(snapshot.startContext()); + return data != null && data.isAttack(); + } + + private static boolean isPregnancySnapshot(@Nullable ServerAnimationController.ActiveInstanceSnapshot snapshot) { + NonAnimationSessionData data = snapshot == null ? null : getNonSessionData(snapshot.startContext()); + return data != null && data.isPregnancy(); + } + + public static void setAnimationStartProtection(ServerPlayerEntity player, int ticks) { + NonAttackSystem.setAnimationStartProtection(player, ticks); + } + + public static boolean hasAnimationStartProtection(ServerWorld world, ServerPlayerEntity player) { + return NonAttackSystem.hasAnimationStartProtection(world, player); + } + + public static boolean isActorPendingOrActive(ServerWorld world, UUID actorUuid) { + if (world == null || actorUuid == null) { + return false; + } else { + return isNoNGatheringActor(world, actorUuid) ? true : AnimationService.isActorPendingOrActive(world, actorUuid); + } + } + + public static boolean isNoNGatheringActor(ServerWorld world, UUID actorUuid) { + if (world != null && actorUuid != null) { + NeedsOfNature.NoNGatherActorState nonGatherState = NON_PENDING_GATHER_ACTORS.get(actorUuid); + if (nonGatherState != null) { + if (world.getRegistryKey().equals(nonGatherState.worldKey()) + && nonGatherState.expiresTick() > world.getTime() + && NonGatherSystem.isActorGathering(actorUuid)) { + return true; + } + + NON_PENDING_GATHER_ACTORS.remove(actorUuid, nonGatherState); + } + + return false; + } else { + return false; + } + } + + public static boolean isMobAttackFailsafeActive(ServerWorld world, UUID mobUuid) { + return NonAttackSystem.isMobAttackFailsafeActive(world, mobUuid); + } + + public static List getActivePlayerCooldownDebugLines(ServerWorld world, ServerPlayerEntity player) { + List lines = new ArrayList<>(); + if (world != null && player != null) { + long nowTick = getGlobalTick(world); + Long lastDefeatedUntil = LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.get(player.getUuid()); + if (lastDefeatedUntil != null) { + long remaining = lastDefeatedUntil - nowTick; + if (remaining > 0L) { + lines.add("last defeated: " + remaining + "t/" + formatCooldownSeconds(remaining) + "s"); + } else { + LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.remove(player.getUuid(), lastDefeatedUntil); + } + } + + Long postEscapeDamageInvulnerableUntil = POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.get(player.getUuid()); + if (postEscapeDamageInvulnerableUntil != null) { + long remaining = postEscapeDamageInvulnerableUntil - nowTick; + if (remaining > 0L) { + lines.add("post-escape damage invulnerability: " + remaining + "t/" + formatCooldownSeconds(remaining) + "s"); + } else { + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.remove(player.getUuid(), postEscapeDamageInvulnerableUntil); + } + } + + if (AnimationService.isActorPendingOrActive(world, player.getUuid())) { + lines.add("animation active/pending"); + } + + if (PREGNANCY_QUEUED_PLAYERS.contains(player.getUuid())) { + lines.add("pregnancy queued"); + } + + Long pregnancyRetry = PREGNANCY_START_RETRY_TICKS.get(player.getUuid()); + if (pregnancyRetry != null) { + long remaining = pregnancyRetry - nowTick; + if (remaining > 0L) { + lines.add("pregnancy retry: " + remaining + "t/" + formatCooldownSeconds(remaining) + "s"); + } + } + + NeedsOfNature.PendingMInjectorReward pendingReward = PENDING_M_INJECTOR_REWARDS.get(player.getUuid()); + if (pendingReward != null) { + lines.add( + pendingReward.freeSinceTick() < 0L + ? "M reward pending: waiting for animation end" + : "M reward pending: free for " + Math.max(0L, nowTick - pendingReward.freeSinceTick()) + "t" + ); + } + + return lines; + } else { + return lines; + } + } + + private static String formatCooldownSeconds(long ticks) { + return String.format(Locale.ROOT, "%.1f", ticks / 20.0); + } + + static int clearAttackOutcomeFailsafeEntries() { + return NonAttackSystem.clearAttackOutcomeFailsafeEntries(); + } + + public static String getMobAttackFailsafeDebug(ServerWorld world, UUID mobUuid) { + return NonAttackSystem.getMobAttackFailsafeDebug(world, mobUuid); + } + + public static String formatTicksForDebug(long ticks) { + long safeTicks = Math.max(0L, ticks); + double seconds = safeTicks / 20.0; + return safeTicks + "t/" + String.format(Locale.ROOT, "%.1fs", seconds); + } + + public static boolean handleAnimationBreedingAttempt(ServerWorld world, AnimalEntity first, AnimalEntity second) { + if (world != null && first != null && second != null) { + NonConfig config = getConfig(); + if (config == null || !config.useAnimationBreeding()) { + return false; + } + + if (first == second) { + return true; + } + + if (first.isAlive() && second.isAlive() && !first.isRemoved() && !second.isRemoved()) { + NeedsOfNature.BreedingPairKey pairKey = breedingPairKey(first.getUuid(), second.getUuid()); + if (pairKey == null) { + return true; + } else if (BREEDING_PENDING_BY_PAIR.containsKey(pairKey)) { + return true; + } else if (BREEDING_ACTIVE_INSTANCE_BY_PAIR.containsKey(pairKey)) { + return true; + } else if (config.requireMaleFemaleForBreeding() && !isMaleFemalePair(first, second)) { + return true; + } else { + List actors = new ArrayList<>(2); + actors.add(first); + actors.add(second); + actors.sort(Comparator.comparingInt(Entity::getId)); + AnimationService.MatchedAnimation match = NonAnimationMatcher.findAutomatic(world, actors, Set.of(), null, false, null, Set.of()); + if (match == null) { + return false; + } else { + List actorKeys = match.actorKeys(); + if (actorKeys != null && actorKeys.size() == actors.size()) { + List stages = match.stages(); + UUID requestId = UUID.randomUUID(); + long expiresTick = world.getTime() + 600L; + NeedsOfNature.PendingBreedingState pending = new NeedsOfNature.PendingBreedingState( + world.getRegistryKey(), pairKey.firstUuid(), pairKey.secondUuid(), expiresTick + ); + BREEDING_PENDING_BY_PAIR.put(pairKey, pending); + BREEDING_PENDING_BY_REQUEST.put(requestId, pairKey); + AnimationStartContext startContext = AnimationStartContext.empty().withSessionData(NonAnimationSessionData.breeding(requestId)); + UUID started = startAnimationNowWithContext( + world, match.animationId(), actors, actorKeys, stages, NonAnimationDamageBehavior.STOP_ON_DAMAGE, false, null, startContext + ); + return true; + } else { + return false; + } + } + } + } else { + return true; + } + } else { + return false; + } + } + + @Nullable + private static NeedsOfNature.BreedingPairKey breedingPairKey(UUID firstUuid, UUID secondUuid) { + if (firstUuid == null || secondUuid == null) { + return null; + } else if (firstUuid.equals(secondUuid)) { + return null; + } else { + return firstUuid.compareTo(secondUuid) <= 0 + ? new NeedsOfNature.BreedingPairKey(firstUuid, secondUuid) + : new NeedsOfNature.BreedingPairKey(secondUuid, firstUuid); + } + } + + private static boolean isMaleFemalePair(Entity first, Entity second) { + if (first != null && second != null) { + boolean firstCanBeMale = NonGenderSystem.hasMaleTag(first); + boolean firstCanBeFemale = NonGenderSystem.hasFemaleTag(first); + boolean secondCanBeMale = NonGenderSystem.hasMaleTag(second); + boolean secondCanBeFemale = NonGenderSystem.hasFemaleTag(second); + return firstCanBeMale && secondCanBeFemale || firstCanBeFemale && secondCanBeMale; + } else { + return false; + } + } + + private static void adoptPendingBreedingRequest(ServerWorld world, UUID instanceId, @Nullable NonAnimationSessionData sessionData) { + if (world != null && instanceId != null && sessionData != null) { + UUID requestId = sessionData.breedingRequestId(); + if (requestId != null) { + NeedsOfNature.BreedingPairKey pairKey = BREEDING_PENDING_BY_REQUEST.remove(requestId); + if (pairKey != null) { + NeedsOfNature.PendingBreedingState pending = BREEDING_PENDING_BY_PAIR.remove(pairKey); + if (pending != null) { + if (world.getRegistryKey().equals(pending.worldKey())) { + NeedsOfNature.ActiveBreedingState active = new NeedsOfNature.ActiveBreedingState( + pending.worldKey(), pending.firstUuid(), pending.secondUuid() + ); + BREEDING_ACTIVE_BY_INSTANCE.put(instanceId, active); + BREEDING_ACTIVE_INSTANCE_BY_PAIR.put(pairKey, instanceId); + } + } + } + } + } + } + + private static void adoptPendingPregnancyHatch(ServerWorld world, UUID instanceId, List actorUuids, @Nullable NonAnimationSessionData sessionData) { + if (world != null && instanceId != null && actorUuids != null && sessionData != null && sessionData.isPregnancy()) { + NonAnimationSessionData.Pregnancy pregnancy = sessionData.pregnancy(); + if (pregnancy != null) { + ServerPlayerEntity player = null; + + for (UUID actorUuid : actorUuids) { + if (world.getEntity(actorUuid) instanceof ServerPlayerEntity p) { + player = p; + break; + } + } + + if (player != null) { + Identifier entityTypeId = pregnancy.sourceEntityTypeId(); + Identifier birthEntityTypeId = pregnancy.birthEntityTypeId(); + String birthMode = pregnancy.birthMode(); + int spawnCount = Math.max(1, Math.min(16, pregnancy.offspringCount())); + NeedsOfNature.PregnancyVariantData variantData = pregnancy.variantData(); + PREGNANCY_HATCH_BY_INSTANCE.put( + instanceId, + new NeedsOfNature.PregnancyHatchState(player.getUuid(), entityTypeId, birthEntityTypeId, birthMode, spawnCount, spawnCount, 0, variantData) + ); + PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.put(player.getUuid(), instanceId); + PREGNANCY_QUEUED_PLAYERS.remove(player.getUuid()); + PREGNANCY_START_RETRY_TICKS.remove(player.getUuid()); + } + } + } + } + + private static void finalizeAnimationBreeding(ServerWorld world, UUID instanceId, boolean peaked) { + if (world != null && instanceId != null) { + NeedsOfNature.ActiveBreedingState active = BREEDING_ACTIVE_BY_INSTANCE.remove(instanceId); + if (active != null) { + NeedsOfNature.BreedingPairKey pairKey = breedingPairKey(active.firstUuid(), active.secondUuid()); + if (pairKey != null) { + BREEDING_ACTIVE_INSTANCE_BY_PAIR.remove(pairKey, instanceId); + } + + if (peaked) { + if (world.getRegistryKey().equals(active.worldKey())) { + Entity firstEntity = world.getEntity(active.firstUuid()); + Entity secondEntity = world.getEntity(active.secondUuid()); + if (firstEntity instanceof AnimalEntity first && secondEntity instanceof AnimalEntity second) { + if (first.isAlive() && second.isAlive() && !first.isRemoved() && !second.isRemoved()) { + if (first.getEntityWorld() == world && second.getEntityWorld() == world) { + PassiveEntity baby = first.createChild(world, second); + if (baby != null) { + baby.setBaby(true); + baby.refreshPositionAndAngles(first.getX(), first.getY(), first.getZ(), 0.0F, 0.0F); + first.breed(world, second, baby); + world.spawnEntityAndPassengers(baby); + } + } + } + } + } + } + } + } + } + + private static void clearPendingBreedingForActors(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty() && !BREEDING_PENDING_BY_PAIR.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + Set actorSet = new HashSet<>(actorUuids); + Set removedPairs = new HashSet<>(); + BREEDING_PENDING_BY_PAIR.entrySet().removeIf(entry -> { + NeedsOfNature.PendingBreedingState state = entry.getValue(); + NeedsOfNature.BreedingPairKey key = entry.getKey(); + if (state == null || key == null) { + return true; + } + + if (!worldKey.equals(state.worldKey())) { + return false; + } + + boolean matchesActors = actorSet.contains(key.firstUuid()) && actorSet.contains(key.secondUuid()); + if (matchesActors) { + removedPairs.add(key); + } + + return matchesActors; + }); + if (!removedPairs.isEmpty()) { + BREEDING_PENDING_BY_REQUEST.entrySet().removeIf(entry -> removedPairs.contains(entry.getValue())); + } + } + } + + private static void prunePendingAnimationBreeding(ServerWorld world, long nowTick) { + if (world != null && !BREEDING_PENDING_BY_PAIR.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + Set removedPairs = new HashSet<>(); + BREEDING_PENDING_BY_PAIR.entrySet().removeIf(entry -> { + NeedsOfNature.PendingBreedingState state = entry.getValue(); + NeedsOfNature.BreedingPairKey key = entry.getKey(); + if (state == null || key == null) { + return true; + } + + if (!worldKey.equals(state.worldKey())) { + return false; + } + + if (state.expiresTick() > nowTick) { + return false; + } + + removedPairs.add(key); + return true; + }); + if (!removedPairs.isEmpty()) { + BREEDING_PENDING_BY_REQUEST.entrySet().removeIf(entry -> removedPairs.contains(entry.getValue())); + } + } + } + + private static void trackPendingGatherPairs(ServerWorld world, List actors) { + if (world != null && actors != null && !actors.isEmpty()) { + long expiresTick = world.getTime() + 6000L; + RegistryKey worldKey = world.getRegistryKey(); + List playerUuids = new ArrayList<>(); + List mobUuids = new ArrayList<>(); + + for (Entity actor : actors) { + if (actor != null && actor.getEntityWorld() == world) { + if (actor instanceof ServerPlayerEntity) { + playerUuids.add(actor.getUuid()); + } else if (actor instanceof MobEntity) { + mobUuids.add(actor.getUuid()); + } + } + } + + if (!playerUuids.isEmpty() && !mobUuids.isEmpty()) { + for (UUID mobUuid : mobUuids) { + for (UUID playerUuid : playerUuids) { + PENDING_GATHER_PAIRS.put(new NeedsOfNature.GatherPairKey(mobUuid, playerUuid), new NeedsOfNature.GatherPairState(worldKey, expiresTick)); + } + } + } + } + } + + private static void clearPendingGatherPairsForActors(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty() && !PENDING_GATHER_PAIRS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + Set actorSet = new HashSet<>(actorUuids); + PENDING_GATHER_PAIRS.entrySet().removeIf(entry -> { + NeedsOfNature.GatherPairState state = entry.getValue(); + NeedsOfNature.GatherPairKey key = entry.getKey(); + if (state == null || key == null) { + return true; + } else { + return !worldKey.equals(state.worldKey()) ? false : actorSet.contains(key.mobUuid()) && actorSet.contains(key.playerUuid()); + } + }); + } + } + + private static void prunePendingGatherPairs(ServerWorld world, long nowTick) { + if (world != null && !PENDING_GATHER_PAIRS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + PENDING_GATHER_PAIRS.entrySet().removeIf(entry -> { + NeedsOfNature.GatherPairState state = entry.getValue(); + NeedsOfNature.GatherPairKey key = entry.getKey(); + if (state == null || key == null) { + return true; + } else if (!worldKey.equals(state.worldKey())) { + return false; + } else if (state.expiresTick() <= nowTick) { + return true; + } else { + return !isActorPendingOrActive(world, key.mobUuid()) ? true : !isActorPendingOrActive(world, key.playerUuid()); + } + }); + NON_PENDING_GATHER_ACTORS.entrySet().removeIf(entry -> { + NeedsOfNature.NoNGatherActorState state = entry.getValue(); + return state == null ? true : worldKey.equals(state.worldKey()) && state.expiresTick() <= nowTick; + }); + } + } + + static boolean isMobGatheringOrActiveWithPlayer(ServerWorld world, MobEntity mob, ServerPlayerEntity player) { + if (world != null && mob != null && player != null) { + UUID playerInstance = getActivePlayerInstance(player); + if (instanceContainsActor(world, playerInstance, mob.getUuid())) { + return true; + } + + NeedsOfNature.GatherPairKey key = new NeedsOfNature.GatherPairKey(mob.getUuid(), player.getUuid()); + NeedsOfNature.GatherPairState state = PENDING_GATHER_PAIRS.get(key); + if (state == null) { + return false; + } + + if (!world.getRegistryKey().equals(state.worldKey()) || state.expiresTick() <= world.getTime()) { + PENDING_GATHER_PAIRS.remove(key); + return false; + } + + if (isActorPendingOrActive(world, mob.getUuid()) && isActorPendingOrActive(world, player.getUuid())) { + return true; + } + + PENDING_GATHER_PAIRS.remove(key); + return false; + } else { + return false; + } + } + + public static NeedsOfNature.AttackBlockReason getMobAttackBlockReason(ServerWorld world, MobEntity mob, ServerPlayerEntity player) { + return NonAttackSystem.getMobAttackBlockReason(world, mob, player); + } + + public static void onMobAttackBlocked(ServerWorld world, MobEntity mob, ServerPlayerEntity player, NeedsOfNature.AttackBlockReason reason) { + NonAttackSystem.onMobAttackBlocked(world, mob, player, reason); + } + + private static boolean hasPlayerAndHostileActor(List actors) { + return NonAttackSystem.hasPlayerAndHostileActor(actors); + } + + private static void recordAttackOutcomeForMobs(ServerWorld world, List actorUuids, String reason) { + NonAttackSystem.recordAttackOutcomeForMobs(world, actorUuids, reason); + } + + public static boolean enqueueAnimation( + ServerWorld world, + Identifier animationId, + List actors, + int insertIndex, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext, + ServerPlayerEntity requester + ) { + if (world != null && animationId != null && actors != null && !actors.isEmpty()) { + boolean effectiveIgnoreAttackers = ignoreAttackers || hasPlayerAndHostileActor(actors); + return ServerAnimationController.enqueueForPlayerWithContext( + world, + requester, + animationId, + actors, + insertIndex, + damageBehavior, + effectiveIgnoreAttackers, + startContext == null ? AnimationStartContext.empty() : startContext, + false + ); + } else { + return false; + } + } + + public static void clearQueuedAnimations(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + AnimationService.clearQueuedAnimations(world, player); + } + } + + public static List getInstanceActors(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + return snapshot == null ? List.of() : snapshot.actorUuids(); + } else { + return List.of(); + } + } + + public static boolean isInstanceAttack(ServerWorld world, UUID instanceId) { + return world != null && instanceId != null ? isAttackSnapshot(ServerAnimationController.getActiveInstanceSnapshot(world, instanceId)) : false; + } + + static boolean isVoluntaryManualPeakInstance(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + NonAnimationSessionData data = snapshot == null ? null : getNonSessionData(snapshot.startContext()); + return data != null && data.isVoluntary() && isManualPeakAnimation(snapshot.animationId()); + } else { + return false; + } + } + + public static void sendDebugChat(ServerPlayerEntity player, String message) { + sendDebugChat(player, NonDebugChatCategory.INFO, message); + } + + public static void sendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, String message) { + sendDebugChat(player, category, Text.literal(message)); + } + + public static void sendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, Text message) { + if (player != null) { + NonDiagnostics.sendDebugChat(player, category, Text.translatable("debug.needsofnature.prefix", new Object[]{message}), false); + } + } + + public static void sendDebugChatToNearby(ServerWorld world, Entity source, double radius, String message) { + sendDebugChatToNearby(world, source, radius, NonDebugChatCategory.INFO, message); + } + + public static void sendDebugChatToNearby(ServerWorld world, Entity source, double radius, NonDebugChatCategory category, String message) { + sendDebugChatToNearby(world, source, radius, category, Text.literal(message)); + } + + public static void sendDebugChatToNearby(ServerWorld world, Entity source, double radius, NonDebugChatCategory category, Text message) { + if (world != null && source != null) { + double maxDistSq = radius * radius; + + for (ServerPlayerEntity p : world.getPlayers()) { + if (p.squaredDistanceTo(source) <= maxDistSq) { + NonDiagnostics.sendDebugChat(p, category, Text.translatable("debug.needsofnature.prefix", new Object[]{message}), false); + } + } + } + } + + public static void logSetupWarning(String template, Object... args) { + NonDiagnostics.logSetupWarning(template, args); + } + + public static void logRuntime(String template, Object... args) { + NonDiagnostics.logRuntime(template, args); + } + + public static void logSetupError(String template, Object... args) { + NonDiagnostics.logSetupError(template, args); + } + + public static void queueSetupChat(String message) { + NonDiagnostics.queueSetupChat(message); + } + + public static String formatLogTemplate(String template, Object... args) { + return NonDiagnostics.formatLogTemplate(template, args); + } + + public static boolean consumeScanBudget(ServerWorld world) { + if (world == null) { + return false; + } + + RegistryKey key = world.getRegistryKey(); + long now = world.getTime(); + int configured = CONFIG.getScanBudgetPerTick(); + NeedsOfNature.ScanBudget budget = SCAN_BUDGETS.get(key); + if (budget == null || budget.tick != now) { + budget = new NeedsOfNature.ScanBudget(now, configured); + } + + if (budget.remaining <= 0) { + SCAN_BUDGETS.put(key, budget); + return false; + } else { + SCAN_BUDGETS.put(key, new NeedsOfNature.ScanBudget(now, budget.remaining - 1)); + return true; + } + } + + private void registerLoopProgression() { + ServerTickEvents.END_WORLD_TICK.register((EndWorldTick)world -> { + long now = world.getTime(); + tickPregnancyCueRuntime(world, now); + tickLiquidDecay(world); + NonPregnancyConceptionSystem.tick(world); + tickPeakLiquidProgression(world, now); + tickReceiverDripParticles(world, now); + tickPregnancy(world); + tickMInjectorRewards(world, now); + tickPendingAttackEscapeResolutions(world, now); + tickPostDeathStateSync(world, now); + NonGatherSystem.tickServerWorld(world); + NonPlayerInteractionSystem.tick(world); + NonPlayerAttackSystem.tick(world); + NonAccessoryShedSystem.tickServerWorld(world); + NonMessSystem.tickMessCleaning(world, now); + if (now % 20L == 0L) { + prunePendingGatherPairs(world, now); + prunePendingAnimationBreeding(world, now); + pruneAttackOutcomeFailsafe(world, now); + pruneAttackBlockDebug(world, now); + } + }); + } + + private static void tickMInjectorRewards(ServerWorld world, long now) { + if (world != null && !PENDING_M_INJECTOR_REWARDS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (Entry entry : PENDING_M_INJECTOR_REWARDS.entrySet()) { + UUID playerUuid = entry.getKey(); + NeedsOfNature.PendingMInjectorReward reward = entry.getValue(); + if (playerUuid != null && reward != null) { + if (worldKey.equals(reward.worldKey())) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(playerUuid); + if (player == null || player.getEntityWorld() != world || !player.isAlive() || player.isRemoved()) { + PENDING_M_INJECTOR_REWARDS.remove(playerUuid, reward); + } else if (AnimationService.isActorPendingOrActive(world, playerUuid)) { + if (reward.freeSinceTick() >= 0L) { + PENDING_M_INJECTOR_REWARDS.put(playerUuid, new NeedsOfNature.PendingMInjectorReward(reward.worldKey(), -1L)); + } + } else { + long freeSince = reward.freeSinceTick(); + if (freeSince < 0L) { + PENDING_M_INJECTOR_REWARDS.put(playerUuid, new NeedsOfNature.PendingMInjectorReward(reward.worldKey(), now)); + } else if (now - freeSince >= 20L) { + applyMInjectorReward(world, player); + PENDING_M_INJECTOR_REWARDS.remove(playerUuid, reward); + } + } + } + } else if (playerUuid != null) { + PENDING_M_INJECTOR_REWARDS.remove(playerUuid); + } + } + } + } + + private static void applyMInjectorReward(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + int foodBefore = player.getHungerManager().getFoodLevel(); + player.getHungerManager().add(1, 0.0F); + if (foodBefore < 20) { + grantEmergencySnackAdvancement(player); + } + + world.playSound( + null, + player.getX(), + player.getY() + player.getStandingEyeHeight() * 0.5, + player.getZ(), + SoundEvents.ITEM_HONEY_BOTTLE_DRINK, + SoundCategory.PLAYERS, + 1.0F, + 0.95F + world.getRandom().nextFloat() * 0.1F + ); + } + } + + private static void tickPeakLiquidProgression(ServerWorld world, long nowTick) { + if (world != null && !PEAK_LIQUID_PROGRESS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (Entry entry : PEAK_LIQUID_PROGRESS.entrySet()) { + UUID instanceId = entry.getKey(); + NeedsOfNature.PeakLiquidProgressState state = entry.getValue(); + if (instanceId != null && state != null) { + if (worldKey.equals(state.worldKey)) { + NeedsOfNature.PeakState peakState = PEAK_STATES.get(instanceId); + if (peakState == null || peakState.lastStage() != peakState.peakStage()) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } else if (!state.infinite) { + while (state.scheduleIndex < state.scheduledTicks.size() && state.scheduledTicks.get(state.scheduleIndex) <= nowTick) { + int amount = state.scheduledAmounts.get(state.scheduleIndex); + state.scheduleIndex++; + if (amount > 0) { + int previous = state.distributedBaseMl; + int next = Math.min(state.plan.maxAmount(), previous + amount); + NeedsOfNature.LiquidPulseResult result = grantLiquidPulseToReceivers( + world, state.plan, previous, next, state.donorHistoryRecordedReceivers + ); + state.distributedBaseMl = next; + recordPeakLiquidPulse(instanceId, result); + if (result.addedMl() > 0) { + addActualAddedMlToPeakState(instanceId, result.addedMl()); + } + } + } + + if (state.scheduleIndex >= state.scheduledTicks.size()) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } + } else if (state.remainingBaseMl > 0 && !(state.cycleTicks <= 0.0) && !state.cueOffsets.isEmpty()) { + int guard = 0; + + while (true) { + if (state.remainingBaseMl > 0 && guard < 64) { + int cueOffset = Math.max(0, state.cueOffsets.get(state.cueIndex)); + double cueAnimTick = state.cycleIndex * state.cycleTicks + cueOffset; + long dueTick = state.stageStartTick + elapsedWorldTicksFromAnimationTicks(cueAnimTick, state.stageSpeed); + if (nowTick >= dueTick) { + int amount = Math.min(5, state.remainingBaseMl); + state.remainingBaseMl -= amount; + int previous = state.distributedBaseMl; + int next = Math.min(state.plan.maxAmount(), previous + amount); + NeedsOfNature.LiquidPulseResult result = grantLiquidPulseToReceivers( + world, state.plan, previous, next, state.donorHistoryRecordedReceivers + ); + state.distributedBaseMl = next; + recordPeakLiquidPulse(instanceId, result); + if (result.addedMl() > 0) { + addActualAddedMlToPeakState(instanceId, result.addedMl()); + } + + state.cueIndex++; + if (state.cueIndex >= state.cueOffsets.size()) { + state.cueIndex = 0; + state.cycleIndex++; + } + + guard++; + continue; + } + } + + if (state.remainingBaseMl <= 0) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } + break; + } + } else { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } + } + } else if (instanceId != null) { + PEAK_LIQUID_PROGRESS.remove(instanceId); + } + } + } + } + + private static void calmMobsAfterPeakedPlayerAnimation(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + Set playerUuids = new HashSet<>(); + + for (UUID actorUuid : actorUuids) { + if (world.getEntity(actorUuid) instanceof ServerPlayerEntity player) { + playerUuids.add(player.getUuid()); + } + } + + if (!playerUuids.isEmpty()) { + for (UUID actorUuid : actorUuids) { + if (world.getEntity(actorUuid) instanceof MobEntity mob) { + if (mob.getTarget() instanceof ServerPlayerEntity player && playerUuids.contains(player.getUuid())) { + mob.setTarget(null); + } + + if (mob instanceof Angerable angerable) { + LazyEntityReference angryAt = angerable.getAngryAt(); + UUID angryAtUuid = angryAt == null ? null : angryAt.getUuid(); + if (angryAtUuid == null || playerUuids.contains(angryAtUuid)) { + angerable.stopAnger(); + } + } + } + } + } + } + } + + private static void pruneAttackOutcomeFailsafe(ServerWorld world, long nowTick) { + NonAttackSystem.pruneAttackOutcomeFailsafe(world, nowTick); + } + + private static void pruneAttackBlockDebug(ServerWorld world, long nowTick) { + NonAttackSystem.pruneAttackBlockDebug(world, nowTick); + } + + private void registerLiquidSync() { + ServerPlayConnectionEvents.JOIN.register((Join)(handler, sender, server) -> { + NonGenderSystem.ensurePlayerGenderInitialized(handler.player); + NonDestroyedSkinSystem.cacheDestroyedSkinStage(handler.player); + NonDestroyedSkinSystem.syncDestroyedSkinStageToOtherPlayers(handler.player); + syncLiquidState(handler.player); + syncPregnancyState(handler.player); + NonPregnancyConceptionSystem.syncState(handler.player); + syncPlayerRuntimeSettings(handler.player); + syncHostConfigState(handler.player); + syncAnimationCatalogState(handler.player); + syncDestroyedSkins(handler.player); + NonMessSystem.syncMessStates(handler.player); + NonAccessoryShedSystem.syncAllToViewer(handler.player); + }); + ServerPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, server) -> { + UUID playerUuid = handler.player.getUuid(); + FILL_BOTTLE_STATES.entrySet().removeIf(entry -> playerUuid.equals(entry.getValue().playerUuid())); + PENDING_GATHER_PAIRS.entrySet().removeIf(entry -> playerUuid.equals(entry.getKey().playerUuid())); + LAST_DEFEATED_COOLDOWN_UNTIL_BY_PLAYER.remove(playerUuid); + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.remove(playerUuid); + PENDING_ATTACK_ESCAPE_RESOLUTIONS.remove(playerUuid); + PENDING_POST_DEATH_STATE_SYNCS.remove(playerUuid); + UUID activeHatchInstance = PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.remove(playerUuid); + if (activeHatchInstance != null) { + PREGNANCY_HATCH_BY_INSTANCE.remove(activeHatchInstance); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(activeHatchInstance); + } + + PREGNANCY_QUEUED_PLAYERS.remove(playerUuid); + PREGNANCY_START_RETRY_TICKS.remove(playerUuid); + NonTrinketsIntegration.clearAccessoryState(playerUuid); + NonAccessoryShedSystem.clearServerShedState(handler.player.getEntityWorld(), playerUuid); + NonAdvancementHooks.clearPlayerState(playerUuid); + NonDestroyedSkinSystem.clearPlayerRuntimeState(server, playerUuid); + }); + } + + public static void syncLiquidState(ServerPlayerEntity player) { + NonLiquidSystem.syncLiquidState(player); + } + + private static void syncPregnancyState(ServerPlayerEntity player) { + if (player != null) { + Identifier entityTypeId = getPregnantEntityTypeId(player); + String raw = entityTypeId == null ? "" : entityTypeId.toString(); + ServerPlayNetworking.send(player, new PregnancyStateS2CPayload(raw)); + } + } + + public static void syncPlayerRuntimeSettings(ServerPlayerEntity player) { + if (player != null) { + NonConfig config = getConfig(); + int attackHits = config.getAttackEscapeHits() + NonTrinketsIntegration.getAccessoryEffects(player).attackEscapeHitsAdd(); + attackHits = Math.max(1, Math.min(50, attackHits)); + ServerPlayNetworking.send(player, new PlayerRuntimeSettingsS2CPayload(attackHits)); + } + } + + public static void syncPlayerRuntimeSettingsToAll(@Nullable MinecraftServer server) { + if (server != null) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + syncPlayerRuntimeSettings(player); + } + } + } + + private static void syncHostConfigState(ServerPlayerEntity player) { + if (player != null) { + NonConfig config = getConfig(); + HostConfigSyncData data = HostConfigSyncData.fromConfig(config); + data.genderSpawnByEntity = new LinkedHashMap<>(NonGenderSystem.resolveEffectiveGenderSpawnMap()); + data.entityProfilesByEntity = new LinkedHashMap<>(NonEntityProfiles.resolveEffectiveProfilesMap()); + data.liquidGainByEntity = new LinkedHashMap<>(resolveEffectiveLiquidGainMap()); + data.liquidColorByEntity = new LinkedHashMap<>(resolveEffectiveLiquidColorMap()); + String json = HOST_CONFIG_SYNC_GSON.toJson(data); + String editableJson = HOST_CONFIG_SYNC_GSON.toJson(HostConfigSyncData.fromEditableConfig(CONFIG)); + ServerPlayNetworking.send(player, new HostConfigSyncS2CPayload(json, editableJson, SERVER_CONFIG_REVISION.get(), canEditServerSettings(player))); + } + } + + private static boolean canEditServerSettings(@Nullable ServerPlayerEntity player) { + if (player != null && player.getEntityWorld().getServer() != null) { + MinecraftServer server = player.getEntityWorld().getServer(); + PlayerConfigEntry entry = new PlayerConfigEntry(player.getGameProfile()); + return server.isHost(entry) || server.getPlayerManager().isOperator(entry); + } else { + return false; + } + } + + private static void registerServerSettingsNetworking() { + ServerPlayNetworking.registerGlobalReceiver(ServerConfigRequestC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + syncHostConfigState(context.player()); + syncAnimationCatalogState(context.player()); + })); + ServerPlayNetworking.registerGlobalReceiver( + ServerConfigUpdateC2SPayload.ID, (payload, context) -> context.server().execute(() -> applyServerConfigUpdate(context.player(), payload)) + ); + } + + private static void applyServerConfigUpdate(ServerPlayerEntity player, ServerConfigUpdateC2SPayload payload) { + if (player != null && payload != null) { + if (!canEditServerSettings(player)) { + rejectServerConfigUpdate(player, "config.needsofnature.server_settings.no_permission"); + } else if (payload.revision() != SERVER_CONFIG_REVISION.get()) { + rejectServerConfigUpdate(player, "config.needsofnature.server_settings.stale"); + } else { + String raw = payload.json(); + if (raw != null && !raw.isBlank() && raw.length() <= 262144) { + try { + HostConfigSyncData requested = (HostConfigSyncData)HOST_CONFIG_SYNC_GSON.fromJson(raw, HostConfigSyncData.class); + if (requested == null) { + rejectServerConfigUpdate(player, "config.needsofnature.server_settings.invalid"); + return; + } + + NonConfig validated = CONFIG.copy(); + requested.applyEditableTo(validated); + HostConfigSyncData.fromEditableConfig(validated).applyEditableTo(CONFIG); + CONFIG.save(); + applyAnimationToggleConfig(); + SERVER_CONFIG_REVISION.incrementAndGet(); + ANIMATION_CATALOG_REVISION.incrementAndGet(); + MinecraftServer server = player.getEntityWorld().getServer(); + syncHostConfigStateToAll(server); + syncAnimationCatalogStateToAll(server); + refreshLiquidBottleTintsToAll(server); + ServerPlayNetworking.send(player, new ServerConfigResultS2CPayload(true, SERVER_CONFIG_REVISION.get(), "")); + } catch (RuntimeException e) { + LOGGER.warn("[NoN] Rejected invalid server settings update from {}.", player.getName().getString(), e); + rejectServerConfigUpdate(player, "config.needsofnature.server_settings.invalid"); + } + } else { + rejectServerConfigUpdate(player, "config.needsofnature.server_settings.invalid"); + } + } + } + } + + private static void rejectServerConfigUpdate(ServerPlayerEntity player, String messageKey) { + syncHostConfigState(player); + syncAnimationCatalogState(player); + ServerPlayNetworking.send(player, new ServerConfigResultS2CPayload(false, SERVER_CONFIG_REVISION.get(), messageKey)); + } + + private static void syncAnimationCatalogState(ServerPlayerEntity player) { + if (player != null) { + sendAnimationCatalog(player, AnimationCatalogData.capture(CONFIG), ANIMATION_CATALOG_REVISION.get()); + } + } + + private static void syncAnimationCatalogStateToAll(@Nullable MinecraftServer server) { + if (server != null) { + AnimationCatalogData catalog = AnimationCatalogData.capture(CONFIG); + long revision = ANIMATION_CATALOG_REVISION.get(); + + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + sendAnimationCatalog(player, catalog, revision); + } + } + } + + private static void sendAnimationCatalog(ServerPlayerEntity player, AnimationCatalogData catalog, long revision) { + String json = HOST_CONFIG_SYNC_GSON.toJson(catalog); + List chunks = splitNetworkString(json, 24000); + + for (int i = 0; i < chunks.size(); i++) { + ServerPlayNetworking.send(player, new AnimationCatalogChunkS2CPayload(revision, i, chunks.size(), chunks.get(i))); + } + } + + private static List splitNetworkString(String value, int maxChars) { + if (value != null && !value.isEmpty()) { + int safeMax = Math.max(1024, maxChars); + List chunks = new ArrayList<>((value.length() + safeMax - 1) / safeMax); + int start = 0; + + while (start < value.length()) { + int end = Math.min(value.length(), start + safeMax); + if (end < value.length() && Character.isHighSurrogate(value.charAt(end - 1)) && Character.isLowSurrogate(value.charAt(end))) { + end--; + } + + chunks.add(value.substring(start, end)); + start = end; + } + + return List.copyOf(chunks); + } else { + return List.of(""); + } + } + + private static void syncDestroyedSkins(ServerPlayerEntity player) { + NonDestroyedSkinSystem.syncDestroyedSkins(player); + } + + private static boolean shouldApplyDestroyedSkinAttackDamage(boolean isAttack, @Nullable AnimationStartContext startContext, @Nullable Identifier animationId) { + if (!isAttack) { + return false; + } else { + boolean joinReplace = startContext != null && startContext.isJoinReplacement(); + boolean defeatedAnimation = hasAnimationTagWithPrefix(animationId, "defeat."); + boolean joinedFromDefeatedAnimation = startContext != null && hasAnimationTagWithPrefix(startContext.joinPredecessor(), "defeat."); + if (!joinReplace || !defeatedAnimation && !joinedFromDefeatedAnimation) { + return joinReplace ? false : !defeatedAnimation; + } else { + return true; + } + } + } + + public static void syncHostConfigStateToAll(@Nullable MinecraftServer server) { + if (server != null) { + clearDisabledSystemStoredStates(server); + NonPlayerInteractionSystem.normalizeAndSyncAll(server); + + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + NonDestroyedSkinSystem.cacheDestroyedSkinStage(player); + syncPlayerRuntimeSettings(player); + syncHostConfigState(player); + syncLiquidState(player); + NonPregnancyConceptionSystem.syncState(player); + syncDestroyedSkins(player); + NonMessSystem.syncMessStates(player); + } + } + } + + private static void clearDisabledSystemStoredStates(MinecraftServer server) { + if (server != null) { + boolean messEnabled = isMessSystemEnabled(); + boolean destroyedSkinEnabled = isDestroyedSkinSystemEnabled(); + if (!messEnabled || !destroyedSkinEnabled) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + if (!messEnabled) { + NonMessSystem.clearMessStoredState(player); + } + + if (!destroyedSkinEnabled) { + NonDestroyedSkinSystem.clearDestroyedSkinStoredState(player); + } + } + } + } + } + + public static void syncLiquidStateToAll(@Nullable MinecraftServer server) { + NonLiquidSystem.syncLiquidStateToAll(server); + } + + public static void refreshLiquidBottleTintsToAll(@Nullable MinecraftServer server) { + NonLiquidSystem.refreshLiquidBottleTintsToAll(server); + } + + static int resolveLiquidTintRgb(LiquidHolder holder) { + return NonLiquidSystem.resolveLiquidTintRgb(holder); + } + + public static int resolveBottleLiquidTintRgb(@Nullable Identifier entityTypeId) { + return NonLiquidSystem.resolveBottleTintRgb(entityTypeId); + } + + private static int grantLiquidOnPeak(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (actorKeys != null && !actorKeys.isEmpty()) { + if (actorKeys.size() != actorUuids.size()) { + return 0; + } + + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles == null) { + return 0; + } + + Set receiverKeys = roles.receiverKeys(); + if (receiverKeys.isEmpty()) { + return 0; + } + + int count = Math.min(actorUuids.size(), actorKeys.size()); + int totalAdded = 0; + + for (int i = 0; i < count; i++) { + if (receiverKeys.contains(actorKeys.get(i)) + && world.getEntity(actorUuids.get(i)) instanceof ServerPlayerEntity player + && player instanceof LiquidHolder holder) { + NeedsOfNature.LiquidGainProfile gain = resolveLiquidGainProfile( + world, actorUuids, actorKeys, animationId, NonGenderSystem.getLiquidTankType(player) + ); + if (gain.amount() > 0) { + NeedsOfNature.LiquidGainProfile playerGain = applyApiLiquidGainModifier(player, applyAccessoryLiquidGainMultiplier(player, gain)); + int added = addLiquidWithComposition(holder, playerGain); + if (added > 0) { + recordRecentLiquidDonors(holder, playerGain.recentDonors()); + totalAdded += added; + Map scaledAmounts = scaleEntityAmounts(playerGain.entityAmounts(), added); + NonStats.addLiquidGained(player, added, scaledAmounts); + trackFieldResearch(world, player, scaledAmounts); + syncLiquidState(player); + } + } + } + } + + return totalAdded; + } else { + return 0; + } + } else { + return 0; + } + } + + private static void fillHorseLiquidCollectorsOnPeak(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (actorKeys != null && !actorKeys.isEmpty()) { + if (actorKeys.size() == actorUuids.size()) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null) { + Set receiverKeys = roles.receiverKeys(); + Set injectorKeys = roles.injectorKeys(); + if (!receiverKeys.isEmpty() && !injectorKeys.isEmpty()) { + List collectors = new ArrayList<>(); + Set donorTypes = new LinkedHashSet<>(); + int count = Math.min(actorUuids.size(), actorKeys.size()); + + for (int i = 0; i < count; i++) { + String actorKey = actorKeys.get(i); + Entity entity = world.getEntity(actorUuids.get(i)); + if (entity != null) { + if (receiverKeys.contains(actorKey) && entity instanceof HorseLiquidCollectorEntity collector) { + collectors.add(collector); + } + + if (injectorKeys.contains(actorKey)) { + EntityType entityType = entity.getType(); + if (entityType == EntityType.HORSE || entityType == EntityType.DONKEY || entityType == EntityType.MULE) { + Identifier typeId = Registries.ENTITY_TYPE.getId(entityType); + donorTypes.add(typeId); + } + } + } + } + + if (!collectors.isEmpty() && !donorTypes.isEmpty()) { + boolean mixedDonor = donorTypes.size() > 1; + Identifier donorType = mixedDonor ? null : donorTypes.iterator().next(); + + for (HorseLiquidCollectorEntity collector : collectors) { + if (mixedDonor) { + collector.absorbMixedLiquid(); + } else { + collector.absorbEntityLiquid(donorType); + } + } + } + } + } + } + } + } + } + + private static NeedsOfNature.LiquidGainProfile resolveLiquidGainProfile( + ServerWorld world, List actorUuids, List actorKeys, Identifier animationId + ) { + return resolveLiquidGainProfile(world, actorUuids, actorKeys, animationId, null); + } + + private static NeedsOfNature.LiquidGainProfile resolveLiquidGainProfile( + ServerWorld world, List actorUuids, List actorKeys, Identifier animationId, @Nullable NonLiquidTankType tankType + ) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (actorKeys != null && !actorKeys.isEmpty()) { + if (actorKeys.size() != actorUuids.size()) { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles == null) { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + + Map injectorRoles = roles.injectorRoles(); + if (injectorRoles.isEmpty()) { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + + double liquidGainMultiplier = resolveLiquidGainMultiplier(animationId); + int total = 0; + int xpEligible = 0; + Set injectorEntityTypes = new LinkedHashSet<>(); + Map entityAmounts = new LinkedHashMap<>(); + NeedsOfNature.PregnancyVariantData lastDonorVariantData = null; + List recentDonors = new ArrayList<>(); + int count = Math.min(actorUuids.size(), actorKeys.size()); + + for (int i = 0; i < count; i++) { + String actorKey = actorKeys.get(i); + NonLiquidRoles.InjectorRole role = injectorRoles.get(actorKey); + if (isLiquidInjectorAccepted(role, tankType)) { + Entity entity = world.getEntity(actorUuids.get(i)); + if (entity != null && !(entity instanceof ServerPlayerEntity player && NonTrinketsIntegration.hasMaleRoleAccessory(player))) { + Identifier typeId = Registries.ENTITY_TYPE.getId(entity.getType()); + int gain = applyLiquidGainMultiplier(resolveEntityLiquidGainMl(typeId), liquidGainMultiplier); + if (gain > 0) { + total += gain; + if (!PLAYER_ENTITY_TYPE_ID.equals(typeId)) { + xpEligible += gain; + } + + injectorEntityTypes.add(typeId); + entityAmounts.merge(typeId, gain, Integer::sum); + NeedsOfNature.PregnancyVariantData donorVariantData = entity instanceof ServerPlayerEntity + ? null + : capturePregnancyVariantData(world, entity); + lastDonorVariantData = donorVariantData; + if (role == NonLiquidRoles.InjectorRole.V && !(entity instanceof ServerPlayerEntity)) { + recentDonors.add(new NeedsOfNature.PregnancyDonor(typeId, donorVariantData)); + } + } + } + } + } + + if (total <= 0) { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + + Map frozenEntityAmounts = Map.copyOf(entityAmounts); + return injectorEntityTypes.size() == 1 + ? new NeedsOfNature.LiquidGainProfile( + total, + xpEligible, + LiquidHolder.LiquidComposition.ENTITY, + injectorEntityTypes.iterator().next(), + frozenEntityAmounts, + lastDonorVariantData, + recentDonors + ) + : new NeedsOfNature.LiquidGainProfile(total, xpEligible, LiquidHolder.LiquidComposition.MIXED, null, frozenEntityAmounts, null, recentDonors); + } else { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + } else { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + } + + public static boolean isProtectedByAccessory(ServerWorld world, AnimationDefinitions.Definition definition, List actors) { + if (world != null && definition != null && actors != null && !actors.isEmpty()) { + for (ServerPlayerEntity player : collectPlayerActors(actors)) { + for (NonLiquidRoles.InjectorRole role : resolveEffectiveInjectorRolesForPlayer(definition.id(), player)) { + if (NonTrinketsIntegration.hasProtectorFor(player, role)) { + return true; + } + } + } + + return false; + } else { + return false; + } + } + + public static boolean consumeProtectorForAnimation(ServerWorld world, AnimationDefinitions.Definition definition, List actors) { + if (world != null && definition != null && actors != null && !actors.isEmpty()) { + for (ServerPlayerEntity player : collectPlayerActors(actors)) { + for (NonLiquidRoles.InjectorRole role : resolveEffectiveInjectorRolesForPlayer(definition.id(), player)) { + if (NonTrinketsIntegration.consumeProtectorFor(world, player, role)) { + return true; + } + } + } + + return false; + } else { + return false; + } + } + + public static void trackNoNGatherPair(ServerWorld world, List actors) { + trackPendingGatherPairs(world, actors); + trackNoNGatherActors(world, actors); + } + + public static void clearNoNGatherPair(ServerWorld world, List actorUuids) { + clearPendingGatherPairsForActors(world, actorUuids); + clearNoNGatherActors(world, actorUuids); + } + + private static void trackNoNGatherActors(ServerWorld world, List actors) { + if (world != null && actors != null && !actors.isEmpty()) { + long expiresTick = world.getTime() + 6000L; + RegistryKey worldKey = world.getRegistryKey(); + + for (Entity actor : actors) { + if (actor != null && actor.getEntityWorld() == world) { + NON_PENDING_GATHER_ACTORS.put(actor.getUuid(), new NeedsOfNature.NoNGatherActorState(worldKey, expiresTick)); + } + } + } + } + + private static void clearNoNGatherActors(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (UUID actorUuid : actorUuids) { + if (actorUuid != null) { + NeedsOfNature.NoNGatherActorState state = NON_PENDING_GATHER_ACTORS.get(actorUuid); + if (state == null || worldKey.equals(state.worldKey())) { + NON_PENDING_GATHER_ACTORS.remove(actorUuid); + } + } + } + } + } + + public static void applyNoNGatherCancellation(ServerWorld world, Identifier animationId, List actorUuids, NonGatherCancelReason reason) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + clearPendingGatherPairsForActors(world, actorUuids); + if (reason == NonGatherCancelReason.NO_LOS || reason == NonGatherCancelReason.TOO_FAR || reason == NonGatherCancelReason.TIMEOUT) { + long now = world.getTime(); + + for (UUID actorId : actorUuids) { + if (world.getEntity(actorId) instanceof EnergyHolder holder) { + for (UUID otherId : actorUuids) { + if (!otherId.equals(actorId)) { + holder.applyPartnerCooldown(otherId, now, 1200); + } + } + } + } + + Entity chatSource = null; + + for (UUID actorId : actorUuids) { + Entity actor = world.getEntity(actorId); + if (actor instanceof MobEntity) { + chatSource = actor; + break; + } + + if (chatSource == null && actor != null) { + chatSource = actor; + } + } + + if (chatSource != null) { + sendDebugChatToNearby( + world, + chatSource, + 20.0, + NonDebugChatCategory.WARNING, + Text.translatable( + "debug.needsofnature.gather_cancelled", + new Object[]{animationId == null ? "unknown" : animationId.toString(), reason.name().toLowerCase(Locale.ROOT)} + ) + ); + } + + logRuntime("[NoN] Gather cancelled: animation={} reason={} actors={}", animationId == null ? "unknown" : animationId, reason, actorUuids); + } + } + } + + public static UUID startAnimationNowWithContext( + ServerWorld world, + Identifier animationId, + List actors, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + AnimationStartContext startContext + ) { + return startAnimationNowWithContext( + world, animationId, actors, actorKeys, stages, damageBehavior, ignoreAttackers, positionAnchor, startContext, null, false + ); + } + + public static UUID startAnimationNowWithContext( + ServerWorld world, + Identifier animationId, + List actors, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + AnimationStartContext startContext, + @Nullable ServerPlayerEntity requester, + boolean forceChat + ) { + return startAnimationNowWithContext( + world, animationId, actors, actorKeys, stages, damageBehavior, ignoreAttackers, positionAnchor, startContext, requester, forceChat, false + ); + } + + public static UUID startAnimationNowWithContext( + ServerWorld world, + Identifier animationId, + List actors, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + AnimationStartContext startContext, + @Nullable ServerPlayerEntity requester, + boolean forceChat, + boolean allowMultiplePlayers + ) { + if (world != null && animationId != null && actors != null && !actors.isEmpty()) { + boolean effectiveIgnoreAttackers = ignoreAttackers || hasPlayerAndHostileActor(actors); + AnimationStartContext safeContext = startContext == null ? AnimationStartContext.empty() : startContext; + return AnimationService.startAnimationNowWithContext( + world, + animationId, + actors, + actorKeys, + stages, + new AnimationService.StartOptions( + damageBehavior, effectiveIgnoreAttackers, positionAnchor, requester, safeContext.addonMetadata(), forceChat, allowMultiplePlayers + ), + safeContext + ); + } else { + return null; + } + } + + public static boolean joinAnimationNow(ServerWorld world, UUID instanceId, List newActors, @Nullable ServerPlayerEntity requester) { + return world != null && instanceId != null && newActors != null && !newActors.isEmpty() + ? joinAnimationNow(world, instanceId, newActors, requester, null) + : false; + } + + @Nullable + public static UUID findActiveInstanceContainingActor(ServerWorld world, UUID actorUuid) { + return ServerAnimationController.findActiveInstanceId(world, actorUuid); + } + + public static boolean isVoluntaryJoinableInstance(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (snapshot != null && !isAttackSnapshot(snapshot) && !isPregnancySnapshot(snapshot)) { + AnimationStageInfo stage = AnimationService.getCurrentStage(world, instanceId); + return stage != null && stage.allowJoin(); + } else { + return false; + } + } else { + return false; + } + } + + public static boolean joinAnimationNow( + ServerWorld world, UUID instanceId, List newActors, @Nullable ServerPlayerEntity requester, @Nullable Identifier expandedAnimationId + ) { + return joinAnimationNow(world, instanceId, newActors, requester, expandedAnimationId, null); + } + + public static boolean joinAnimationNow( + ServerWorld world, + UUID instanceId, + List newActors, + @Nullable ServerPlayerEntity requester, + @Nullable Identifier expandedAnimationId, + @Nullable Entity positionAnchor + ) { + return joinAnimationNow(world, instanceId, newActors, requester, expandedAnimationId, positionAnchor, null); + } + + public static boolean joinAnimationNow( + ServerWorld world, + UUID instanceId, + List newActors, + @Nullable ServerPlayerEntity requester, + @Nullable Identifier expandedAnimationId, + @Nullable Entity positionAnchor, + @Nullable List exactActorKeys + ) { + if (world != null && instanceId != null && newActors != null && !newActors.isEmpty()) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (snapshot == null) { + return false; + } + + boolean attackContinuation = isAttackSnapshot(snapshot); + AnimationStageInfo currentStage = AnimationService.getCurrentStage(world, instanceId); + if (currentStage != null && currentStage.allowJoin()) { + Identifier predecessorAnimationId = AnimationService.getActiveAnimationId(world, instanceId); + if (predecessorAnimationId == null) { + return false; + } + + List existingActorUuids = snapshot.actorUuids(); + if (existingActorUuids.isEmpty()) { + return false; + } + + List combined = collectEntities(world, existingActorUuids); + if (combined.isEmpty()) { + return false; + } + + Set existing = new HashSet<>(existingActorUuids); + int originalCount = combined.size(); + + for (Entity actor : newActors) { + if (actor != null + && actor.getEntityWorld() == world + && !actor.isRemoved() + && !existing.contains(actor.getUuid()) + && !( + actor instanceof ServerPlayerEntity playerActor + ? playerActor.isSpectator() + : actor instanceof PlayerEntity || !(actor instanceof MobEntity) + )) { + combined.add(actor); + existing.add(actor.getUuid()); + } + } + + if (combined.size() <= originalCount) { + return false; + } + + combined.sort(Comparator.comparingInt(Entity::getId)); + AnimationDefinitions.Definition definition = findJoinDefinition(world, combined, expandedAnimationId, predecessorAnimationId, attackContinuation); + if (definition == null) { + return false; + } + + boolean eligible = exactActorKeys == null + ? AnimationService.isAnimationStartEligible(world, definition, combined, positionAnchor) + : AnimationService.isAnimationStartEligible(world, definition, combined, exactActorKeys, positionAnchor); + if (!eligible) { + return false; + } + + List actorKeys = exactActorKeys == null + ? AnimationDefinitions.resolveActorKeys(definition, combined, world.getRandom()) + : List.copyOf(exactActorKeys); + if (actorKeys != null && actorKeys.size() == combined.size()) { + AnimationStartContext replacementContext = snapshot.startContext(); + if (requester != null && isDefeatedInstance(world, instanceId)) { + replacementContext = NonPlayerAttackSystem.prepareDefeatedJoinContext(world, existingActorUuids, requester, replacementContext); + } + + replacementContext = replacementContext.withJoinPredecessor(predecessorAnimationId); + if (!ServerAnimationController.stopInstance(world, instanceId, false)) { + return false; + } + + UUID replacement = startAnimationNowWithContext( + world, + definition.id(), + combined, + actorKeys, + definition.stages(), + NonPlayerAttackSystem.isPlayerAttackContext(replacementContext) ? NonAnimationDamageBehavior.STOP_ON_DAMAGE : snapshot.damageBehavior(), + snapshot.ignoreAttackers(), + positionAnchor, + replacementContext, + requester, + false, + true + ); + return replacement != null; + } else { + return false; + } + } else { + return false; + } + } else { + return false; + } + } + + @Nullable + private static AnimationDefinitions.Definition findJoinDefinition( + ServerWorld world, List combined, @Nullable Identifier expandedAnimationId, Identifier predecessorAnimationId, boolean attackContinuation + ) { + if (world == null || combined == null || combined.isEmpty() || predecessorAnimationId == null) { + return null; + } + + if (expandedAnimationId == null) { + AnimationService.MatchedAnimation matched = attackContinuation + ? NonAnimationMatcher.findAutomaticAttack(world, combined, Set.of(), null, false, null, Set.of(), null, predecessorAnimationId) + : NonAnimationMatcher.findAutomatic(world, combined, Set.of(), null, false, null, Set.of(), null, predecessorAnimationId); + return matched == null ? null : AnimationDefinitions.getDefinition(matched.animationId()); + } + + if (!AnimationService.isJoinTransitionAllowed(predecessorAnimationId, expandedAnimationId)) { + return null; + } + + AnimationDefinitions.MatchResult match = AnimationDefinitions.match(combined); + List candidates = match.candidatesSorted(); + if (candidates != null && !candidates.isEmpty()) { + for (AnimationDefinitions.Definition candidate : candidates) { + if (candidate != null && expandedAnimationId.equals(candidate.id())) { + return attackContinuation && !candidate.attackEligible() ? null : candidate; + } + } + + return null; + } else { + return null; + } + } + + private static List collectEntities(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + List actors = new ArrayList<>(); + + for (UUID actorUuid : actorUuids) { + Entity entity = world.getEntity(actorUuid); + if (entity != null && !entity.isRemoved()) { + actors.add(entity); + } + } + + actors.sort(Comparator.comparingInt(Entity::getId)); + return actors; + } else { + return List.of(); + } + } + + private static Set resolveEffectiveInjectorRolesForPlayer(Identifier animationId, ServerPlayerEntity player) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && !roles.injectorRoles().isEmpty()) { + LinkedHashSet out = new LinkedHashSet<>(); + + for (NonLiquidRoles.InjectorRole role : roles.injectorRoles().values()) { + NonLiquidRoles.InjectorRole effective = role; + if (effective == NonLiquidRoles.InjectorRole.V + && player != null + && NonGenderSystem.isOnlyMale(player) + && getConfig().convertMaleOnlyVInjectionsToA()) { + effective = NonLiquidRoles.InjectorRole.A; + } + + if (effective != null && effective != NonLiquidRoles.InjectorRole.NONE) { + out.add(effective); + } + } + + return Set.copyOf(out); + } else { + return Set.of(); + } + } + + private static boolean isLiquidInjectorAccepted(@Nullable NonLiquidRoles.InjectorRole role, @Nullable NonLiquidTankType tankType) { + if (role != NonLiquidRoles.InjectorRole.V && role != NonLiquidRoles.InjectorRole.A) { + return false; + } + + if (tankType == null) { + return true; + } + + NonLiquidRoles.InjectorRole effective = role; + if (tankType == NonLiquidTankType.A && role == NonLiquidRoles.InjectorRole.V && getConfig().convertMaleOnlyVInjectionsToA()) { + effective = NonLiquidRoles.InjectorRole.A; + } + + return tankType.accepts(effective); + } + + private static NeedsOfNature.LiquidGainProfile scaleLiquidGainProfile(NeedsOfNature.LiquidGainProfile gain, double scale) { + if (gain != null && gain.amount() > 0) { + double safe = Double.isFinite(scale) && !(scale < 0.0) ? scale : 1.0; + int amount = applyLiquidGainMultiplier(gain.amount(), safe); + int xpEligible = Math.min(amount, applyLiquidGainMultiplier(gain.xpEligibleAmount(), safe)); + Map entityAmounts = new LinkedHashMap<>(); + + for (Entry entry : gain.entityAmounts().entrySet()) { + int scaled = applyLiquidGainMultiplier(entry.getValue(), safe); + if (scaled > 0) { + entityAmounts.put(entry.getKey(), scaled); + } + } + + return new NeedsOfNature.LiquidGainProfile( + amount, + xpEligible, + amount > 0 ? gain.composition() : LiquidHolder.LiquidComposition.EMPTY, + amount > 0 ? gain.entityTypeId() : null, + Map.copyOf(entityAmounts), + amount > 0 ? gain.donorVariantData() : null, + amount > 0 ? gain.recentDonors() : List.of() + ); + } else { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } + } + + private static double resolveLiquidGainMultiplier(@Nullable Identifier animationId) { + Double value = AnimationGameplayMetadata.liquidGainMultiplier(animationId); + return value != null && Double.isFinite(value) && !(value < 0.0) ? value : 1.0; + } + + private static int applyLiquidGainMultiplier(int baseGain, double multiplier) { + if (baseGain <= 0) { + return 0; + } else { + double safe = Double.isFinite(multiplier) && !(multiplier < 0.0) ? multiplier : 1.0; + long scaled = Math.round(baseGain * safe); + if (scaled <= 0L) { + return 0; + } else { + return scaled > 2147483647L ? Integer.MAX_VALUE : (int)scaled; + } + } + } + + private static NeedsOfNature.LiquidGainProfile applyAccessoryLiquidGainMultiplier(ServerPlayerEntity player, NeedsOfNature.LiquidGainProfile gain) { + if (player != null && gain != null && gain.amount() > 0) { + double multiplier = NonTrinketsIntegration.getActiveTankAccessoryEffects(player).liquidGainMultiplier(); + if (Double.isFinite(multiplier) && !(Math.abs(multiplier - 1.0) < 1.0E-6)) { + int amount = applyLiquidGainMultiplier(gain.amount(), multiplier); + int xpEligible = Math.min(amount, applyLiquidGainMultiplier(gain.xpEligibleAmount(), multiplier)); + Map entityAmounts = new LinkedHashMap<>(); + + for (Entry entry : gain.entityAmounts().entrySet()) { + int scaled = applyLiquidGainMultiplier(entry.getValue(), multiplier); + if (scaled > 0) { + entityAmounts.put(entry.getKey(), scaled); + } + } + + return new NeedsOfNature.LiquidGainProfile( + amount, + xpEligible, + amount > 0 ? gain.composition() : LiquidHolder.LiquidComposition.EMPTY, + amount > 0 ? gain.entityTypeId() : null, + Map.copyOf(entityAmounts), + amount > 0 ? gain.donorVariantData() : null, + amount > 0 ? gain.recentDonors() : List.of() + ); + } else { + return gain; + } + } else { + return gain; + } + } + + private static NeedsOfNature.LiquidGainProfile applyApiLiquidGainModifier(ServerPlayerEntity player, NeedsOfNature.LiquidGainProfile gain) { + if (player != null && gain != null && gain.amount() > 0) { + int modifiedAmount = ((NonEvents.ModifyLiquidGain)NonEvents.MODIFY_LIQUID_GAIN.invoker()).modifyLiquidGain(player, gain.entityTypeId(), gain.amount()); + if (modifiedAmount <= 0) { + return new NeedsOfNature.LiquidGainProfile(0, 0, LiquidHolder.LiquidComposition.EMPTY, null, Map.of(), null, List.of()); + } else { + return modifiedAmount == gain.amount() ? gain : scaleLiquidGainProfile(gain, (double)modifiedAmount / gain.amount()); + } + } else { + return gain; + } + } + + private static int resolveEntityLiquidGainMl(@Nullable Identifier entityTypeId) { + if (entityTypeId == null) { + return 0; + } else { + String key = entityTypeId.toString(); + Map configMap = getConfig().getLiquidGainByEntity(); + if (configMap.containsKey(key)) { + Integer configured = configMap.get(key); + int value = configured == null ? 0 : configured; + return Math.max(0, Math.min(1000, value)); + } else { + Integer packOverride = NonLiquidGainOverrides.getGain(entityTypeId); + return packOverride != null ? Math.max(0, Math.min(1000, packOverride)) : 0; + } + } + } + + private static int resolveEntityLiquidColorRgb(@Nullable Identifier entityTypeId) { + if (entityTypeId == null) { + return 16776427; + } + + String key = entityTypeId.toString(); + String configuredHex = getConfig().getLiquidColorByEntity().get(key); + Integer configuredRgb = NonLiquidColors.parseHexColor(configuredHex); + if (configuredRgb != null) { + return configuredRgb; + } + + Integer packOverride = NonLiquidGainOverrides.getColorRgb(entityTypeId); + return packOverride != null ? packOverride : 16776427; + } + + private static int resolveMixedLiquidColorRgb() { + String configuredHex = getConfig().getLiquidColorByEntity().get("needsofnature:mixed"); + Integer configuredRgb = NonLiquidColors.parseHexColor(configuredHex); + if (configuredRgb != null) { + return configuredRgb; + } + + Integer packOverride = NonLiquidGainOverrides.getMixedColorRgb(); + return packOverride != null ? packOverride : 15920063; + } + + private static Map resolveEffectiveLiquidGainMap() { + return NonLiquidSystem.resolveEffectiveLiquidGainMap(); + } + + private static Map resolveEffectiveLiquidColorMap() { + return NonLiquidSystem.resolveEffectiveLiquidColorMap(); + } + + private static int resolveStartStage(List stages) { + int startStage = 1; + if (stages != null && !stages.isEmpty()) { + Integer parsed = NonPeakStages.stageNumberFromId(stages.getFirst().animationId()); + if (parsed != null) { + startStage = parsed; + } + } + + return startStage; + } + + private static int resolveLoopSeconds(Identifier animationId, Integer stageNumber) { + Identifier stageId = resolveStageId(animationId, stageNumber); + Integer override = AnimationGameplayMetadata.stageSeconds(stageId); + if (override != null) { + return override; + } + + Integer baseOverride = AnimationGameplayMetadata.stageSeconds(animationId); + if (baseOverride != null) { + return baseOverride; + } + + Integer peakStage = NonPeakStages.getPeakStage(animationId); + return stageNumber != null && Objects.equals(stageNumber, peakStage) ? CONFIG.getPeakLoopProgressSeconds() : CONFIG.getLoopProgressSeconds(); + } + + private static long resolveLoopAdvanceIntervalTicks(Identifier animationId, Integer stageNumber, @Nullable AnimationStageInfo currentStage) { + long targetTicks = resolveLoopDurationTargetTicks(animationId, stageNumber); + if (targetTicks < 0L) { + return -1L; + } + + Identifier stageId = resolveStageId(animationId, stageNumber); + double exactCycleTicks = exactCycleTicks(stageId, currentStage); + if (currentStage != null && currentStage.loop() && !(exactCycleTicks <= 0.0)) { + double speed = resolveStageSpeed(currentStage); + double cycleWorldTicks = exactCycleTicks / speed; + if (Double.isFinite(cycleWorldTicks) && !(cycleWorldTicks <= 0.0)) { + long cycleCount = Math.max(1L, Math.round(targetTicks / cycleWorldTicks)); + return Math.max(1L, Math.round(cycleCount * cycleWorldTicks)); + } else { + return targetTicks; + } + } else { + return targetTicks; + } + } + + private static long resolveLoopDurationTargetTicks(Identifier animationId, Integer stageNumber) { + int loopSeconds = resolveLoopSeconds(animationId, stageNumber); + if (loopSeconds < 0) { + return -1L; + } + + Identifier stageId = resolveStageId(animationId, stageNumber); + double multiplier = AnimationGameplayMetadata.stageDurationMultiplier(stageId); + return Math.max(1L, Math.round(loopSeconds * 20.0 * multiplier)); + } + + private static double exactCycleTicks(@Nullable Identifier stageId, @Nullable AnimationStageInfo stage) { + double exact = AnimationGameplayMetadata.cycleTicks(stageId); + if (exact > 0.0) { + return exact; + } else { + return stage == null ? 0.0 : Math.max(0L, stage.lengthTicks()); + } + } + + private static Identifier resolveStageId(Identifier animationId, Integer stageNumber) { + return animationId != null && stageNumber != null ? AnimationIds.stageId(animationId, stageNumber) : animationId; + } + + private static void queueDefeatedTailForInstance(ServerWorld world, UUID instanceId, List actorUuids) { + if (world != null && instanceId != null && actorUuids != null && !actorUuids.isEmpty()) { + if (DEFEATED_QUEUED_INSTANCES.add(instanceId)) { + ServerPlayerEntity player = NonPlayerAttackSystem.resolveAttackVictim(world, instanceId, actorUuids); + if (player == null) { + DEFEATED_QUEUED_INSTANCES.remove(instanceId); + } else { + AnimationService.MatchedAnimation defeatMatch = NonDefeatedAnimationSelector.find( + world, player, resolveInstanceBaseAnimationId(world, instanceId) + ); + if (defeatMatch == null) { + DEFEATED_QUEUED_INSTANCES.remove(instanceId); + LOGGER.warn( + "[NoN] No defeat-tag animation matched for player actor. world={} instance={} player={}", + new Object[]{world.getRegistryKey().getValue(), instanceId, player.getName().getString()} + ); + sendDebugChat(player, NonDebugChatCategory.SETUP, Text.translatable("debug.needsofnature.no_defeat_tag_animation")); + } else { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (snapshot == null) { + DEFEATED_QUEUED_INSTANCES.remove(instanceId); + } else { + boolean inheritIgnoreAttackers = snapshot.ignoreAttackers(); + AnimationStartContext defeatedContext = NonPlayerAttackSystem.defeatedTailContext(snapshot.startContext(), player.getUuid()); + boolean queued = enqueueAnimation( + world, + defeatMatch.animationId(), + List.of(player), + -1, + NonPlayerAttackSystem.defeatedDamageBehavior(defeatedContext), + inheritIgnoreAttackers, + defeatedContext, + player + ); + if (!queued) { + DEFEATED_QUEUED_INSTANCES.remove(instanceId); + } else { + NonAdvancementHooks.recordTossedAround(player); + } + } + } + } + } + } + } + + @Nullable + private static NeedsOfNature.LastDefeatedCandidate findLastDefeatedCandidate(ServerWorld world, ServerPlayerEntity player, MobEntity damagingMob) { + if (world != null && player != null && damagingMob != null) { + NeedsOfNature.LastDefeatedCandidate direct = resolveLastDefeatedCandidate(world, player, damagingMob); + if (direct != null) { + return direct; + } + + int radius = getConfig().getLastDefeatedSearchRadius(); + Box box = player.getBoundingBox().expand(radius); + List nearby = world.getEntitiesByClass( + MobEntity.class, box, mobx -> mobx != null && mobx != damagingMob && mobx.isAlive() && !mobx.isRemoved() + ); + nearby.sort(Comparator.comparingDouble(player::squaredDistanceTo)); + + for (MobEntity mob : nearby) { + NeedsOfNature.LastDefeatedCandidate candidate = resolveLastDefeatedCandidate(world, player, mob); + if (candidate != null) { + return candidate; + } + } + + return null; + } else { + return null; + } + } + + @Nullable + private static NeedsOfNature.LastDefeatedCandidate resolveLastDefeatedCandidate(ServerWorld world, ServerPlayerEntity player, MobEntity mob) { + if (world != null && player != null && mob != null) { + if (mob.getEntityWorld() != world || player.getEntityWorld() != world) { + return null; + } + + if (mob.isAlive() && !mob.isRemoved()) { + if (!(mob instanceof EnergyHolder holder && holder.getEnergy() >= getConfig().getLastDefeatedEnergyThreshold())) { + return null; + } else { + if (!canMobAttackPlayer(player) || isMobAttackFailsafeActive(world, mob.getUuid())) { + return null; + } + + if (AnimationService.isActorPendingOrActive(world, mob.getUuid())) { + return null; + } + + List actors = new ArrayList<>(2); + actors.add(mob); + actors.add(player); + actors.sort(Comparator.comparingInt(Entity::getId)); + NonGatherSystem.GatherCandidate gatherCandidate = NonGatherSystem.findUnprotectedGatherCandidate(world, actors, true, mob, player); + if (gatherCandidate == null) { + return null; + } + + AnimationService.MatchedAnimation defeatMatch = NonDefeatedAnimationSelector.find(world, player, gatherCandidate.animationId()); + return defeatMatch == null ? null : new NeedsOfNature.LastDefeatedCandidate(mob, gatherCandidate, defeatMatch); + } + } else { + return null; + } + } else { + return null; + } + } + + private static boolean hasVanillaTotemReady(ServerPlayerEntity player) { + return player == null ? false : player.getMainHandStack().isOf(Items.TOTEM_OF_UNDYING) || player.getOffHandStack().isOf(Items.TOTEM_OF_UNDYING); + } + + @Nullable + private static Identifier resolveInstanceBaseAnimationId(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + NeedsOfNature.PeakState peakState = PEAK_STATES.get(instanceId); + if (peakState != null && peakState.animationId() != null) { + return NonPeakStages.baseAnimationId(peakState.animationId()); + } + + AnimationStageInfo stage = AnimationService.getCurrentStage(world, instanceId); + return stage != null && stage.animationId() != null ? NonPeakStages.baseAnimationId(stage.animationId()) : null; + } else { + return null; + } + } + + private static void tickLiquidDecay(ServerWorld world) { + NonLiquidSystem.tickLiquidDecay(world); + } + + private static void updateFilledEffect(ServerPlayerEntity player, LiquidHolder holder) { + NonLiquidSystem.updateFilledEffect(player, holder); + } + + public static void updateEnergizedEffect(ServerPlayerEntity player, EnergyHolder holder) { + if (player != null && holder != null) { + int amplifier = getEnergizedAmplifier(holder.getEnergy()); + RegistryEntry entry = Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGIZED); + StatusEffectInstance current = player.getStatusEffect(entry); + if (amplifier < 0) { + if (current != null) { + player.removeStatusEffect(entry); + } + } else { + if (current != null) { + if (current.getAmplifier() == amplifier) { + return; + } + + player.removeStatusEffect(entry); + } + + player.addStatusEffect(new StatusEffectInstance(entry, -1, amplifier, false, false, true)); + } + } + } + + private static int getEnergizedAmplifier(int energy) { + if (energy >= 200) { + return 2; + } else if (energy >= 150) { + return 1; + } else { + return energy >= 100 ? 0 : -1; + } + } + + private static void scheduleReceiverDripOnPeak(ServerWorld world, List actorUuids, List actorKeys, Identifier animationId, long nowTick) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (actorKeys != null && actorKeys.size() == actorUuids.size()) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null) { + Set receiverKeys = roles.receiverKeys(); + if (!receiverKeys.isEmpty()) { + int count = Math.min(actorUuids.size(), actorKeys.size()); + long nextTick = nowTick + 20L; + long endTick = nowTick + 200L; + NeedsOfNature.LiquidGainProfile gain = resolveLiquidGainProfile(world, actorUuids, actorKeys, animationId); + int tintRgb = gain.composition() == LiquidHolder.LiquidComposition.ENTITY + ? resolveEntityLiquidColorRgb(gain.entityTypeId()) + : resolveMixedLiquidColorRgb(); + + for (int i = 0; i < count; i++) { + if (receiverKeys.contains(actorKeys.get(i)) + && world.getEntity(actorUuids.get(i)) instanceof MobEntity mob + && !(mob instanceof HorseLiquidCollectorEntity) + && mob.isAlive() + && !mob.isRemoved()) { + RECEIVER_DRIP.put(mob.getUuid(), new NeedsOfNature.ReceiverDripState(world.getRegistryKey(), nextTick, endTick, tintRgb)); + } + } + } + } + } + } + } + + private static void tickReceiverDripParticles(ServerWorld world, long nowTick) { + if (world != null && !RECEIVER_DRIP.isEmpty()) { + Map updates = new HashMap<>(); + Set removals = new HashSet<>(); + + for (Entry entry : RECEIVER_DRIP.entrySet()) { + UUID uuid = entry.getKey(); + NeedsOfNature.ReceiverDripState state = entry.getValue(); + if (state.worldKey.equals(world.getRegistryKey())) { + if (nowTick > state.endTick) { + removals.add(uuid); + } else if (!(world.getEntity(uuid) instanceof MobEntity mob && mob.isAlive() && !mob.isRemoved())) { + removals.add(uuid); + } else if (nowTick >= state.nextTick) { + Vec3d spawnPos = resolveReceiverDripSpawnPos(mob); + world.spawnParticles(NonParticles.liquidFalling(state.tintRgb), spawnPos.x, spawnPos.y, spawnPos.z, 1, 0.0, 0.0, 0.0, 0.0); + long nextTick = state.nextTick + 20L; + if (nextTick > state.endTick) { + removals.add(uuid); + } else { + updates.put(uuid, new NeedsOfNature.ReceiverDripState(state.worldKey, nextTick, state.endTick, state.tintRgb)); + } + } + } + } + + for (UUID uuid : removals) { + RECEIVER_DRIP.remove(uuid); + } + + for (Entry update : updates.entrySet()) { + if (!removals.contains(update.getKey())) { + RECEIVER_DRIP.put(update.getKey(), update.getValue()); + } + } + } + } + + private static Vec3d resolveReceiverDripSpawnPos(MobEntity mob) { + Vec3d localOffset = resolveReceiverDripOffset(mob); + double yawRad = Math.toRadians(mob.getYaw()); + double sin = Math.sin(yawRad); + double cos = Math.cos(yawRad); + Vec3d forward = new Vec3d(-sin, 0.0, cos); + Vec3d right = new Vec3d(forward.z, 0.0, -forward.x); + return new Vec3d(mob.getX(), mob.getY(), mob.getZ()).add(right.multiply(localOffset.x)).add(0.0, localOffset.y, 0.0).add(forward.multiply(localOffset.z)); + } + + private static Vec3d resolveReceiverDripOffset(MobEntity mob) { + Identifier entityId = Registries.ENTITY_TYPE.getId(mob.getType()); + return RECEIVER_DRIP_OFFSETS.getOrDefault(entityId, RECEIVER_DRIP_OFFSET_DEFAULT); + } + + private void registerVoluntaryStopRequests() { + ServerPlayNetworking.registerGlobalReceiver(StopVoluntaryAnimationC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (player.getEntityWorld() instanceof ServerWorld world) { + UUID var7 = payload.instanceId(); + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, var7); + NonAnimationSessionData data = snapshot == null ? null : getNonSessionData(snapshot.startContext()); + if (data != null && data.isVoluntary() && !data.isPregnancy()) { + if (snapshot.actorUuids().contains(player.getUuid())) { + clearQueuedAnimations(world, player); + AnimationService.stopAnimation(world, var7); + } + } + } + })); + } + + private void registerManualPeakRequests() { + ServerPlayNetworking.registerGlobalReceiver( + StartManualPeakC2SPayload.ID, + (payload, context) -> context.server() + .execute( + () -> { + ServerPlayerEntity player = context.player(); + if (player.getEntityWorld() instanceof ServerWorld world) { + if (!player.isSpectator()) { + if (!hasActivePlayerAnimation(player)) { + if (player.isOnGround()) { + NonManualPeakDefinitions.Selection selection = NonManualPeakDefinitions.select(world, player); + if (selection != null) { + List actors = List.of(player); + AnimationDefinitions.Definition definition = selection.definition(); + UUID instanceId = startAnimationNowWithContext( + world, + definition.id(), + actors, + selection.actorKeys(), + selection.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + null, + buildModeContext(false) + ); + if (instanceId != null && selection.matchedHeldItemId() != null) { + NonAdvancementHooks.grantAcquireHardware(player); + } + } + } + } + } + } + } + ) + ); + } + + private void registerFillBottleUseTrigger() { + UseItemCallback.EVENT.register((UseItemCallback)(player, world, hand) -> { + if (world instanceof ServerWorld serverWorld && player instanceof ServerPlayerEntity serverPlayer) { + return (ActionResult)(tryStartFillBottleAnimationOnUse(serverWorld, serverPlayer, hand) ? ActionResult.SUCCESS : ActionResult.PASS); + } else { + return ActionResult.PASS; + } + }); + UseBlockCallback.EVENT.register((UseBlockCallback)(player, world, hand, hitResult) -> { + if (!(world instanceof ServerWorld serverWorld && player instanceof ServerPlayerEntity serverPlayer)) { + return ActionResult.PASS; + } else if (world.getFluidState(hitResult.getBlockPos()).isIn(FluidTags.WATER)) { + return ActionResult.PASS; + } else if (world.getBlockState(hitResult.getBlockPos()).isOf(Blocks.WATER_CAULDRON)) { + return ActionResult.PASS; + } else { + return (ActionResult)(tryStartFillBottleAnimationOnUse(serverWorld, serverPlayer, hand) ? ActionResult.SUCCESS : ActionResult.PASS); + } + }); + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (world instanceof ServerWorld serverWorld && player instanceof ServerPlayerEntity serverPlayer) { + return (ActionResult)(tryStartFillBottleAnimationOnUse(serverWorld, serverPlayer, hand) ? ActionResult.SUCCESS : ActionResult.PASS); + } else { + return ActionResult.PASS; + } + }); + } + + private static boolean tryStartFillBottleAnimationOnUse(ServerWorld world, ServerPlayerEntity player, Hand hand) { + if (world == null || player == null || hand == null) { + return false; + } + + if (player.isSpectator()) { + return false; + } + + if (player.isTouchingWater()) { + return false; + } + + if (hasActivePlayerAnimation(player)) { + return false; + } + + if (!player.isOnGround()) { + return false; + } + + if (player.getPitch() < 80.0F) { + return false; + } + + if (!player.getStackInHand(hand).isOf(Items.GLASS_BOTTLE)) { + return false; + } + + if (player instanceof LiquidHolder liquidHolder) { + updateFilledEffect(player, liquidHolder); + int requiredMl = getFillBottleRequiredMl(liquidHolder); + if (liquidHolder.getLiquidStored() < requiredMl) { + return false; + } else if (!hasFilledStageOneOrHigher(player)) { + return false; + } else { + Identifier fillBottleId = findMatchedAnimationIdByTags(List.of(player), Set.of("fillBottle")); + if (fillBottleId == null) { + return false; + } else { + AnimationDefinitions.Definition fillDefinition = AnimationDefinitions.getDefinition(fillBottleId); + if (fillDefinition == null) { + return false; + } else if (!NonItemSystem.consumeOneGlassBottle(player, hand)) { + return false; + } else { + List actors = List.of(player); + List actorKeys = AnimationDefinitions.resolveActorKeys(fillDefinition, actors, world.getRandom()); + UUID instanceId = startAnimationNowWithContext( + world, + fillDefinition.id(), + actors, + actorKeys, + fillDefinition.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + null, + buildModeContext(false) + ); + if (instanceId != null) { + FILL_BOTTLE_STATES.put(instanceId, new NeedsOfNature.FillBottleState(player.getUuid(), true)); + return true; + } else { + NonItemSystem.refundGlassBottle(player); + return false; + } + } + } + } + } else { + return false; + } + } + + private static void applyAttackEscapeDamage( + ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids, @Nullable UUID preferredAttackerUuid + ) { + NonAttackSystem.applyAttackEscapeDamage(world, player, actorUuids, preferredAttackerUuid); + } + + private static void scheduleAttackEscapeResolution( + ServerWorld world, + ServerPlayerEntity player, + @Nullable List actorUuids, + @Nullable UUID preferredAttackerUuid, + boolean allowReattack, + int invulnerabilityTicks + ) { + if (world != null && player != null) { + List actors = actorUuids != null && !actorUuids.isEmpty() ? List.copyOf(actorUuids) : List.of(); + PENDING_ATTACK_ESCAPE_RESOLUTIONS.put( + player.getUuid(), + new NeedsOfNature.PendingAttackEscapeResolution( + world.getRegistryKey(), actors, preferredAttackerUuid, world.getTime() + 1L, allowReattack, invulnerabilityTicks + ) + ); + } + } + + private static void tickPendingAttackEscapeResolutions(ServerWorld world, long nowTick) { + if (world != null && !PENDING_ATTACK_ESCAPE_RESOLUTIONS.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (Entry entry : PENDING_ATTACK_ESCAPE_RESOLUTIONS.entrySet()) { + UUID playerUuid = entry.getKey(); + NeedsOfNature.PendingAttackEscapeResolution resolution = entry.getValue(); + if (playerUuid != null && resolution != null) { + if (worldKey.equals(resolution.worldKey()) + && nowTick >= resolution.dueTick() + && PENDING_ATTACK_ESCAPE_RESOLUTIONS.remove(playerUuid, resolution)) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(playerUuid); + if (player != null && player.getEntityWorld() == world && player.isAlive() && !player.isRemoved()) { + POST_ESCAPE_DAMAGE_INVULNERABLE_UNTIL_BY_PLAYER.remove(playerUuid); + applyAttackEscapeDamage(world, player, resolution.actorUuids(), resolution.preferredAttackerUuid()); + if (player.isAlive() && !player.isRemoved()) { + if (resolution.invulnerabilityTicks() > 0) { + setPostEscapeDamageInvulnerable(player, resolution.invulnerabilityTicks()); + } + + if (resolution.allowReattack()) { + startPostEscapeReattack(world, player, resolution.actorUuids()); + } + } + } + } + } else if (playerUuid != null) { + PENDING_ATTACK_ESCAPE_RESOLUTIONS.remove(playerUuid); + } + } + } + } + + private static void startPostEscapeReattack(ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids) { + if (world != null && player != null && player.isAlive() && !player.isRemoved()) { + NonAttackSystem.startNearbyPostEscapeGathers(world, player, actorUuids); + requestOriginalAttackers(world, player, actorUuids); + } + } + + private static void requestOriginalAttackers(ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids) { + if (world != null && player != null && player.isAlive() && !player.isRemoved()) { + if (actorUuids != null && !actorUuids.isEmpty()) { + for (UUID actor : actorUuids) { + if (actor != null + && !actor.equals(player.getUuid()) + && world.getEntity(actor) instanceof MobEntity mob + && mob.isAlive() + && mob instanceof EnergyHolder holder) { + holder.requestAttackOnPlayer(world, player); + } + } + } + } + } + + public static boolean isDefeatedInstance(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + NeedsOfNature.PeakState peakState = PEAK_STATES.get(instanceId); + if (peakState != null && hasAnimationTagWithPrefix(peakState.animationId, "defeat.")) { + return true; + } else { + AnimationStageInfo stage = AnimationService.getCurrentStage(world, instanceId); + if (stage != null && stage.animationId() != null) { + Identifier baseAnimationId = NonPeakStages.baseAnimationId(stage.animationId()); + return hasAnimationTagWithPrefix(baseAnimationId, "defeat."); + } else { + return false; + } + } + } else { + return false; + } + } + + private static boolean isManualPeakAnimation(Identifier animationId) { + return hasAnimationTag(animationId, "manualPeak"); + } + + private static boolean isFillBottleAnimation(Identifier animationId) { + return hasAnimationTag(animationId, "fillBottle"); + } + + private static boolean isBirthAnimation(Identifier animationId) { + return hasAnimationTag(animationId, "birth") || hasAnimationTagWithPrefix(animationId, "birth_"); + } + + private static boolean isWaterAnimation(Identifier animationId) { + if (animationId == null) { + return false; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(NonPeakStages.baseAnimationId(animationId)); + return definition != null && definition.waterRequirement() != AnimationDefinitions.WaterRequirement.NONE; + } + + private static boolean hasAnimationTag(Identifier animationId, String tag) { + if (animationId != null && tag != null && !tag.isBlank()) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(NonPeakStages.baseAnimationId(animationId)); + if (definition == null) { + return false; + } + + String expected = tag.trim().toLowerCase(Locale.ROOT); + if (expected.isEmpty()) { + return false; + } + + Set tags = definition.animationTags(); + if (tags != null && !tags.isEmpty()) { + for (String value : tags) { + if (value != null && expected.equals(value.trim().toLowerCase(Locale.ROOT))) { + return true; + } + } + + return false; + } else { + return false; + } + } else { + return false; + } + } + + private static boolean hasAnimationTagWithPrefix(Identifier animationId, String prefix) { + if (animationId != null && prefix != null && !prefix.isBlank()) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(NonPeakStages.baseAnimationId(animationId)); + if (definition == null) { + return false; + } + + String expectedPrefix = prefix.trim().toLowerCase(Locale.ROOT); + if (expectedPrefix.isEmpty()) { + return false; + } + + Set tags = definition.animationTags(); + if (tags != null && !tags.isEmpty()) { + for (String value : tags) { + if (value != null && value.trim().toLowerCase(Locale.ROOT).startsWith(expectedPrefix)) { + return true; + } + } + + return false; + } else { + return false; + } + } else { + return false; + } + } + + private static Identifier findMatchedAnimationIdByTags(List actors, Set requiredTags) { + if (actors != null && !actors.isEmpty() && requiredTags != null && !requiredTags.isEmpty()) { + List sorted = new ArrayList<>(actors.size()); + sorted.addAll(actors); + sorted.sort(Comparator.comparingInt(Entity::getId)); + Set safeTags = new LinkedHashSet<>(); + + for (String tag : requiredTags) { + if (tag != null) { + String normalized = tag.trim(); + if (!normalized.isEmpty()) { + safeTags.add(normalized); + } + } + } + + return safeTags.isEmpty() ? null : AnimationService.findMatchedAnimationId(sorted, safeTags); + } else { + return null; + } + } + + private static void maybeStartPregnancyOnPeak( + ServerWorld world, + List actorUuids, + List actorKeys, + Identifier animationId, + Map> accessoryDonorsByReceiver + ) { + if (!getConfig().isLiquidBasedPregnancyEnabled()) { + if (world != null && actorUuids != null && actorKeys != null) { + if (!actorUuids.isEmpty() && !actorKeys.isEmpty()) { + if (actorUuids.size() == actorKeys.size()) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null) { + Map injectorRoles = roles.injectorRoles(); + if (!injectorRoles.isEmpty()) { + List donors = new ArrayList<>(); + + for (int i = 0; i < actorUuids.size(); i++) { + if (isLiquidInjectorAccepted(injectorRoles.get(actorKeys.get(i)), NonLiquidTankType.V)) { + Entity actor = world.getEntity(actorUuids.get(i)); + if (actor != null && !(actor instanceof ServerPlayerEntity)) { + donors.add( + new NeedsOfNature.PregnancyDonor(Registries.ENTITY_TYPE.getId(actor.getType()), capturePregnancyVariantData(world, actor)) + ); + } + } + } + + long nowTick = getGlobalTick(world); + + for (ServerPlayerEntity player : collectPlayerReceiverActors(world, actorUuids, actorKeys, animationId)) { + if (player != null + && player.isAlive() + && !player.isRemoved() + && !NonGenderSystem.isOnlyMale(player) + && getPregnantEntityTypeId(player) == null + && !NonTrinketsIntegration.blocksPregnancy(player)) { + NonAccessoryEffects accessoryEffects = NonTrinketsIntegration.getActiveTankAccessoryEffects(player); + List eligibleDonors = new ArrayList<>(donors); + if (accessoryDonorsByReceiver != null) { + eligibleDonors.addAll(accessoryDonorsByReceiver.getOrDefault(player.getUuid(), List.of())); + } + + if (!eligibleDonors.isEmpty()) { + NeedsOfNature.PregnancyDonor chosen = eligibleDonors.get(world.getRandom().nextInt(eligibleDonors.size())); + Identifier chosenType = chosen.entityTypeId(); + int chancePercent = NonEntityProfiles.resolve(chosenType).pregnancyChancePercent(); + if (chancePercent > 0) { + chancePercent = applyFertilityIncreaserChance(player, chancePercent); + chancePercent = (int)Math.round(chancePercent * accessoryEffects.pregnancyChanceMultiplier()); + chancePercent = Math.max(0, Math.min(100, chancePercent)); + if (chancePercent > 0 && (chancePercent >= 100 || world.getRandom().nextInt(100) < chancePercent)) { + long durationTicks = Math.max( + 1L, Math.round(getConfig().getPregnancyDurationTicks() * accessoryEffects.pregnancyDurationMultiplier()) + ); + beginPregnancy(player, chosenType, nowTick + durationTicks, chosen.variantData()); + NonStats.incrementPregnancyStarted(player); + NonAdvancementHooks.grantFirstPregnancy(player); + } + } + } + } + } + } + } + } + } + } + } + } + + @Nullable + static NeedsOfNature.PregnancyVariantData capturePregnancyVariantData(ServerWorld world, Entity entity) { + if (world != null && entity != null) { + Map components = new LinkedHashMap<>(); + + for (NeedsOfNature.VariantComponentDescriptor descriptor : PREGNANCY_VARIANT_COMPONENTS) { + descriptor.capture(world, entity, components); + } + + Integer horseMarking = null; + if (entity instanceof HorseEntity horse) { + horseMarking = horse.getMarking().getIndex(); + } + + return normalizePregnancyVariantData(new NeedsOfNature.PregnancyVariantData(components, horseMarking)); + } else { + return null; + } + } + + @Nullable + public static NeedsOfNature.PregnancyVariantData normalizePregnancyVariantData(@Nullable NeedsOfNature.PregnancyVariantData data) { + return data != null && !data.isEmpty() ? data : null; + } + + @Nullable + public static String encodePregnancyVariantData(@Nullable NeedsOfNature.PregnancyVariantData data) { + NeedsOfNature.PregnancyVariantData normalized = normalizePregnancyVariantData(data); + return normalized == null ? null : HOST_CONFIG_SYNC_GSON.toJson(normalized); + } + + @Nullable + public static NeedsOfNature.PregnancyVariantData decodePregnancyVariantData(@Nullable String raw) { + if (raw != null && !raw.isBlank()) { + try { + return normalizePregnancyVariantData( + (NeedsOfNature.PregnancyVariantData)HOST_CONFIG_SYNC_GSON.fromJson(raw, NeedsOfNature.PregnancyVariantData.class) + ); + } catch (RuntimeException e) { + LOGGER.warn("[NoN] Ignoring malformed pregnancy variant data.", e); + return null; + } + } else { + return null; + } + } + + private static void applyPregnancyVariantData(ServerWorld world, Entity entity, @Nullable NeedsOfNature.PregnancyVariantData data) { + NeedsOfNature.PregnancyVariantData normalized = normalizePregnancyVariantData(data); + if (world != null && entity != null && normalized != null) { + Map components = normalized.components(); + + for (NeedsOfNature.VariantComponentDescriptor descriptor : PREGNANCY_VARIANT_COMPONENTS) { + descriptor.apply(world, entity, components.get(descriptor.key())); + } + + if (normalized.horseMarkingIndex() != null && entity instanceof HorseEntity horse) { + HorseMarking marking = HorseMarking.byIndex(normalized.horseMarkingIndex()); + HorseColor color = horse.getHorseColor(); + Method method = findMethod(horse.getClass(), "setHorseVariant", HorseColor.class, HorseMarking.class); + if (method != null) { + tryInvokeMethod(horse, method, color, marking); + } + } + } + } + + public static void applyPregnancyVariantDataForEgg(ServerWorld world, Entity entity, @Nullable NeedsOfNature.PregnancyVariantData data) { + applyPregnancyVariantData(world, entity, data); + } + + private static NeedsOfNature.VariantComponentDescriptor registryVariant( + String key, ComponentType> componentType, RegistryKey> registryKey + ) { + return new NeedsOfNature.VariantComponentDescriptor() { + @Override + public String key() { + return key; + } + + @Override + public void capture(ServerWorld world, Entity entity, Map out) { + RegistryEntry entry = (RegistryEntry)entity.get(componentType); + if (entry != null) { + entry.getKey().map(RegistryKey::getValue).ifPresent(id -> out.put(key, id.toString())); + } + } + + @Override + public void apply(ServerWorld world, Entity entity, String value) { + Identifier id = Identifier.tryParse(value == null ? "" : value); + if (id != null) { + world.getRegistryManager() + .getOptional(registryKey) + .flatMap(registry -> registry.getEntry(id)) + .ifPresent(entry -> entity.setComponent(componentType, entry)); + } + } + }; + } + + private static NeedsOfNature.VariantComponentDescriptor lazyRegistryVariant( + String key, ComponentType> componentType, RegistryKey> registryKey + ) { + return new NeedsOfNature.VariantComponentDescriptor() { + @Override + public String key() { + return key; + } + + @Override + public void capture(ServerWorld world, Entity entity, Map out) { + LazyRegistryEntryReference value = (LazyRegistryEntryReference)entity.get(componentType); + if (value != null) { + Optional> keyValue = value.getKey(); + if (keyValue.isEmpty()) { + keyValue = value.resolveEntry(world.getRegistryManager()).flatMap(RegistryEntry::getKey); + } + + keyValue.map(RegistryKey::getValue).ifPresent(id -> out.put(key, id.toString())); + } + } + + @Override + public void apply(ServerWorld world, Entity entity, String value) { + Identifier id = Identifier.tryParse(value == null ? "" : value); + if (id != null) { + RegistryKey valueKey = RegistryKey.of(registryKey, id); + entity.setComponent(componentType, new LazyRegistryEntryReference(valueKey)); + } + } + }; + } + + private static > NeedsOfNature.VariantComponentDescriptor enumVariant(String key, ComponentType componentType, Class enumClass) { + return new NeedsOfNature.VariantComponentDescriptor() { + @Override + public String key() { + return key; + } + + @Override + public void capture(ServerWorld world, Entity entity, Map out) { + E value = (E)entity.get(componentType); + if (value != null) { + out.put(key, value.name()); + } + } + + @Override + public void apply(ServerWorld world, Entity entity, String value) { + if (value != null && !value.isBlank()) { + try { + entity.setComponent(componentType, Enum.valueOf(enumClass, value)); + } catch (IllegalArgumentException ignored) { + NeedsOfNature.LOGGER.warn("[NoN] Ignoring unknown pregnancy variant value {}={}.", key, value); + } + } + } + }; + } + + static int applyFertilityIncreaserChance(ServerPlayerEntity player, int baseChancePercent) { + int base = Math.max(0, Math.min(100, baseChancePercent)); + return Math.max(base, getFertilityIncreaserChanceFloor(player)); + } + + static int getFertilityIncreaserChanceFloor(ServerPlayerEntity player) { + if (player == null) { + return 0; + } + + int boostedFloor = 0; + + for (StatusEffectInstance effect : player.getStatusEffects()) { + if (effect != null && effect.getEffectType().value() == NonStatusEffects.FERTILITY_INCREASER) { + int floor = effect.getAmplifier() >= 1 ? 95 : 80; + boostedFloor = Math.max(boostedFloor, floor); + } + } + + return boostedFloor; + } + + private static void refreshPregnancyCueRuntime(ServerWorld world, UUID instanceId, long stageStartTick, Identifier fallbackAnimationId) { + if (world != null && instanceId != null) { + NeedsOfNature.PregnancyHatchState hatchState = PREGNANCY_HATCH_BY_INSTANCE.get(instanceId); + if (hatchState == null) { + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(instanceId); + } else { + AnimationStageInfo currentStage = AnimationService.getCurrentStage(world, instanceId); + Identifier clipId = currentStage != null && currentStage.animationId() != null ? currentStage.animationId() : fallbackAnimationId; + if (clipId == null) { + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(instanceId); + } else { + Integer cueTick = AnimationCueIndex.getFirstCueTick(clipId, "birth"); + long startTick = Math.max(0L, stageStartTick); + double birthLoopMultiplier = pregnancyBirthLoopSpeedMultiplier(clipId, hatchState); + double runtimeStageSpeed = clampAnimationSpeed(resolveStageSpeed(currentStage) * birthLoopMultiplier); + NeedsOfNature.PregnancyCueRuntime existing = PREGNANCY_CUE_RUNTIME_BY_INSTANCE.get(instanceId); + if (existing == null + || !existing.worldKey().equals(world.getRegistryKey()) + || existing.stageStartTick() != startTick + || !existing.clipId().equals(clipId) + || !Objects.equals(existing.cueTick(), cueTick) + || !(Math.abs(existing.stageSpeed() - runtimeStageSpeed) < 1.0E-4)) { + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.put( + instanceId, new NeedsOfNature.PregnancyCueRuntime(world.getRegistryKey(), startTick, clipId, cueTick, false, runtimeStageSpeed) + ); + applyPregnancyBirthLoopSpeed(world, instanceId, birthLoopMultiplier); + syncPregnancyLoopProgressDuration(world, instanceId, currentStage, runtimeStageSpeed); + Integer stageNumber = NonPeakStages.stageNumberFromId(clipId); + boolean cueExpected = stageNumber != null && stageNumber >= 2; + if (cueTick == null && cueExpected && (existing == null || !clipId.equals(existing.clipId()) || existing.cueTick() != null)) { + LOGGER.warn("[NoN] Missing birth cue in birth clip {}.", clipId); + ServerPlayerEntity player = findPlayerOnWorldServer(world, hatchState.playerUuid()); + if (player != null && player.getEntityWorld() == world) { + sendDebugChat( + player, NonDebugChatCategory.SETUP, Text.translatable("debug.needsofnature.missing_birth_cue", new Object[]{clipId.toString()}) + ); + } + } + } + } + } + } + } + + private static void applyPregnancyBirthLoopSpeed(ServerWorld world, UUID instanceId, double multiplier) { + if (world != null && instanceId != null) { + if (!(Math.abs(multiplier - 1.0) < 1.0E-4)) { + ServerAnimationController.adjustSpeed(world, instanceId, multiplier, null); + } + } + } + + private static void syncPregnancyLoopProgressDuration(ServerWorld world, UUID instanceId, @Nullable AnimationStageInfo stage, double stageSpeed) { + if (world != null && instanceId != null && stage != null) { + Integer stageNumber = NonPeakStages.stageNumberFromId(stage.animationId()); + if (stageNumber != null && stageNumber == 2) { + long cycleWorldTicks = Math.max(1L, elapsedWorldTicksFromAnimationTicks(Math.max(1L, stage.lengthTicks()), stageSpeed)); + ServerAnimationController.setCurrentStageProgressDuration(world, instanceId, cycleWorldTicks); + } + } + } + + private static double pregnancyBirthLoopSpeedMultiplier(Identifier clipId, NeedsOfNature.PregnancyHatchState hatchState) { + Integer stageNumber = NonPeakStages.stageNumberFromId(clipId); + if (stageNumber == null || stageNumber != 2) { + return 1.0; + } + + if (hatchState == null) { + return 1.0; + } + + int pendingRepeatsBeforeFinalLoop = Math.max(0, hatchState.remainingSpawns() - 2); + if (pendingRepeatsBeforeFinalLoop <= 0) { + return 1.0; + } + + int variableRepeatCount = Math.max(1, hatchState.totalSpawnCount() - 2); + double normalizedPending = Math.max(0.0, Math.min(1.0, (double)pendingRepeatsBeforeFinalLoop / variableRepeatCount)); + double eased = 1.0 - Math.pow(1.0 - normalizedPending, 2.5); + double speedRange = 1.0; + return Math.max(1.0, Math.min(2.0, 1.0 + speedRange * eased)); + } + + private static void tickPregnancyCueRuntime(ServerWorld world, long nowTick) { + if (world != null && !PREGNANCY_CUE_RUNTIME_BY_INSTANCE.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + + for (Entry entry : PREGNANCY_CUE_RUNTIME_BY_INSTANCE.entrySet()) { + UUID instanceId = entry.getKey(); + NeedsOfNature.PregnancyCueRuntime runtime = entry.getValue(); + if (runtime.worldKey().equals(worldKey)) { + Integer cueTick = runtime.cueTick(); + long cueDueTick = runtime.stageStartTick() + + elapsedWorldTicksFromAnimationTicks(cueTick == null ? -1.0 : cueTick.intValue(), runtime.stageSpeed()); + if (!runtime.cueTriggered() && cueTick != null && cueTick >= 0 && nowTick >= cueDueTick) { + NeedsOfNature.PregnancyCueRuntime triggered = new NeedsOfNature.PregnancyCueRuntime( + runtime.worldKey(), runtime.stageStartTick(), runtime.clipId(), runtime.cueTick(), true, runtime.stageSpeed() + ); + if (PREGNANCY_CUE_RUNTIME_BY_INSTANCE.replace(instanceId, runtime, triggered)) { + handlePregnancyBirthCue(world, instanceId); + runtime = triggered; + } + } + + if (runtime.cueTriggered()) { + routePregnancyBirthStageAfterCycle(world, instanceId, runtime, nowTick); + } + } + } + } + } + + private static void tickPregnancy(ServerWorld world) { + if (world != null && !world.getPlayers().isEmpty()) { + long globalTick = getGlobalTick(world); + + for (ServerPlayerEntity player : world.getPlayers()) { + tickPregnancyForPlayer(world, player, globalTick); + } + } + } + + private static void tickPregnancyForPlayer(ServerWorld world, ServerPlayerEntity player, long nowTick) { + if (world != null && player != null) { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null) { + Identifier entityTypeId = holder.getPregnantEntityTypeId(); + if (entityTypeId == null) { + PREGNANCY_START_RETRY_TICKS.remove(player.getUuid()); + } else if (NonGenderSystem.isOnlyMale(player)) { + clearPregnancyState(player, true); + } else if (player.isAlive() && !player.isRemoved()) { + if (!holder.isPregnancyPendingHatch()) { + long endTick = holder.getPregnancyEndTick(); + if (endTick > nowTick) { + if (!player.hasStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT))) { + clearPregnancyState(player, false); + } + + return; + } + + markPregnancyPending(player, nowTick); + } else { + refreshPendingPregnancyEffect(player); + } + + tryStartPendingPregnancyHatch(world, player, nowTick); + } + } + } + } + + private static void tryStartPendingPregnancyHatch(ServerWorld world, ServerPlayerEntity player, long nowTick) { + if (world != null && player != null) { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null && holder.isPregnancyPendingHatch()) { + if (NonGenderSystem.isOnlyMale(player)) { + clearPregnancyState(player, true); + } else if (player.isAlive() && !player.isRemoved() && !player.isSpectator()) { + if (player.getEntityWorld() == world) { + UUID activeInstance = PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.get(player.getUuid()); + if (activeInstance != null) { + if (hasActivePlayerAnimation(player)) { + return; + } + + PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.remove(player.getUuid(), activeInstance); + PREGNANCY_HATCH_BY_INSTANCE.remove(activeInstance); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(activeInstance); + } + + if (hasActivePlayerAnimation(player)) { + queuePendingPregnancyHatch(world, player, holder, nowTick); + } else { + if (PREGNANCY_QUEUED_PLAYERS.contains(player.getUuid())) { + if (AnimationService.isActorPendingOrActive(world, player.getUuid())) { + return; + } + + PREGNANCY_QUEUED_PLAYERS.remove(player.getUuid()); + } + + if (!player.isTouchingWater() && !player.isSubmergedInWater()) { + if (player.isOnGround()) { + long retryAt = PREGNANCY_START_RETRY_TICKS.getOrDefault(player.getUuid(), 0L); + if (retryAt <= nowTick) { + Identifier entityTypeId = holder.getPregnantEntityTypeId(); + if (entityTypeId != null) { + Identifier animationId = resolveBirthAnimationId(player, entityTypeId); + if (animationId == null) { + LOGGER.warn( + "[NoN] Missing birth-tag animation for player {}; clearing pregnancy for {}.", + player.getName().getString(), + entityTypeId + ); + sendDebugChat( + player, + NonDebugChatCategory.SETUP, + Text.translatable("debug.needsofnature.missing_birth_animation", new Object[]{entityTypeId.toString()}) + ); + clearPregnancyState(player, true); + } else { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(animationId); + if (definition == null) { + LOGGER.warn("[NoN] Missing birth animation definition {}.", animationId); + sendDebugChat( + player, + NonDebugChatCategory.SETUP, + Text.translatable("debug.needsofnature.missing_birth_animation", new Object[]{entityTypeId.toString()}) + ); + clearPregnancyState(player, true); + } else { + List actors = List.of(player); + List actorKeys = AnimationDefinitions.resolveActorKeys(definition, actors, world.getRandom()); + int spawnCount = resolvePregnancyOffspringCount(holder); + NonEntityProfiles.ResolvedProfile profile = NonEntityProfiles.resolve(entityTypeId); + AnimationStartContext startContext = buildPregnancyHatchContext( + entityTypeId, profile, spawnCount, holder.getPregnancyVariantData() + ); + UUID started = startAnimationNowWithContext( + world, + definition.id(), + actors, + actorKeys, + definition.stages(), + NonAnimationDamageBehavior.BLOCK_DAMAGE, + true, + null, + startContext + ); + if (started == null) { + PREGNANCY_START_RETRY_TICKS.put(player.getUuid(), nowTick + 20L); + } else { + PREGNANCY_HATCH_BY_INSTANCE.putIfAbsent( + started, + new NeedsOfNature.PregnancyHatchState( + player.getUuid(), + entityTypeId, + profile.birthEntityTypeId(), + profile.birthMode(), + spawnCount, + spawnCount, + 0, + holder.getPregnancyVariantData() + ) + ); + PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.put(player.getUuid(), started); + PREGNANCY_START_RETRY_TICKS.remove(player.getUuid()); + refreshPregnancyCueRuntime(world, started, nowTick, definition.id()); + } + } + } + } + } + } + } + } + } + } + } + } + } + + private static void queuePendingPregnancyHatch(ServerWorld world, ServerPlayerEntity player, PregnancyHolder holder, long nowTick) { + if (world != null && player != null && holder != null) { + if (NonGenderSystem.isOnlyMale(player)) { + clearPregnancyState(player, true); + } else { + UUID playerUuid = player.getUuid(); + if (!PREGNANCY_QUEUED_PLAYERS.contains(playerUuid)) { + long retryAt = PREGNANCY_START_RETRY_TICKS.getOrDefault(playerUuid, 0L); + if (retryAt <= nowTick) { + Identifier entityTypeId = holder.getPregnantEntityTypeId(); + if (entityTypeId != null) { + Identifier animationId = resolveBirthAnimationId(player, entityTypeId); + if (animationId == null) { + LOGGER.warn("[NoN] Missing birth-tag animation for player {}; clearing pregnancy for {}.", player.getName().getString(), entityTypeId); + sendDebugChat( + player, + NonDebugChatCategory.SETUP, + Text.translatable("debug.needsofnature.missing_birth_animation", new Object[]{entityTypeId.toString()}) + ); + clearPregnancyState(player, true); + } else { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(animationId); + if (definition == null) { + LOGGER.warn("[NoN] Missing birth animation definition {}.", animationId); + sendDebugChat( + player, + NonDebugChatCategory.SETUP, + Text.translatable("debug.needsofnature.missing_birth_animation", new Object[]{entityTypeId.toString()}) + ); + clearPregnancyState(player, true); + } else { + int spawnCount = resolvePregnancyOffspringCount(holder); + NonEntityProfiles.ResolvedProfile profile = NonEntityProfiles.resolve(entityTypeId); + boolean queued = enqueueAnimation( + world, + definition.id(), + List.of(player), + 1, + NonAnimationDamageBehavior.BLOCK_DAMAGE, + true, + buildPregnancyHatchContext(entityTypeId, profile, spawnCount, holder.getPregnancyVariantData()), + player + ); + if (!queued) { + PREGNANCY_START_RETRY_TICKS.put(playerUuid, nowTick + 20L); + } else { + PREGNANCY_QUEUED_PLAYERS.add(playerUuid); + PREGNANCY_START_RETRY_TICKS.remove(playerUuid); + } + } + } + } + } + } + } + } + } + + private static AnimationStartContext buildPregnancyHatchContext( + Identifier entityTypeId, NonEntityProfiles.ResolvedProfile profile, int spawnCount, @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + Identifier birthEntityTypeId = profile == null ? entityTypeId : profile.birthEntityTypeId(); + String birthMode = profile == null ? "direct" : profile.birthMode(); + NonAnimationSessionData.Pregnancy pregnancy = new NonAnimationSessionData.Pregnancy( + entityTypeId, birthEntityTypeId, birthMode, Math.max(1, Math.min(16, spawnCount)), variantData + ); + return AnimationStartContext.empty().withSessionData(NonAnimationSessionData.pregnancy(pregnancy)); + } + + private static int resolvePregnancyOffspringCount(@Nullable PregnancyHolder holder) { + int offspringCount = holder == null ? 0 : holder.getPregnancyOffspringCount(); + return offspringCount <= 0 ? 1 : Math.max(1, Math.min(16, offspringCount)); + } + + private static int rollPregnancyOffspringCount(ServerPlayerEntity player, Identifier entityTypeId, int exactOverride) { + if (exactOverride > 0) { + return Math.max(1, Math.min(16, exactOverride)); + } + + NonConfig.OffspringCountRange range = NonEntityProfiles.resolve(entityTypeId).offspringRange(); + int min = range.min(); + int max = range.max(); + return max <= min ? min : min + player.getRandom().nextInt(max - min + 1); + } + + private static Identifier resolveBirthAnimationId(ServerPlayerEntity player, Identifier entityTypeId) { + if (player != null && entityTypeId != null) { + List actors = List.of(player); + Identifier specific = findMatchedAnimationIdByTags(actors, Set.of("birth_" + sanitizeBirthTagPart(entityTypeId))); + return specific != null ? specific : findMatchedAnimationIdByTags(actors, Set.of("birth")); + } else { + return null; + } + } + + private static String sanitizeBirthTagPart(Identifier entityTypeId) { + String raw = entityTypeId.toString().toLowerCase(Locale.ROOT); + StringBuilder out = new StringBuilder(raw.length()); + + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if ((c < 'a' || c > 'z') && (c < '0' || c > '9')) { + out.append('_'); + } else { + out.append(c); + } + } + + return out.toString(); + } + + private static void routePregnancyBirthStage(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + NeedsOfNature.PregnancyHatchState hatchState = PREGNANCY_HATCH_BY_INSTANCE.get(instanceId); + if (hatchState != null && hatchState.remainingSpawns() <= 1) { + AnimationStageInfo currentStage = AnimationService.getCurrentStage(world, instanceId); + Integer stageNumber = NonPeakStages.stageNumberFromId(currentStage == null ? null : currentStage.animationId()); + if (stageNumber != null && stageNumber == 2) { + AnimationService.advanceToStage(world, instanceId, 3); + } + } + } + } + + private static void routePregnancyBirthStageAfterCycle(ServerWorld world, UUID instanceId, NeedsOfNature.PregnancyCueRuntime runtime, long nowTick) { + if (world != null && instanceId != null && runtime != null) { + NeedsOfNature.PregnancyHatchState hatchState = PREGNANCY_HATCH_BY_INSTANCE.get(instanceId); + if (hatchState != null && hatchState.remainingSpawns() > 0) { + AnimationStageInfo currentStage = AnimationService.getCurrentStage(world, instanceId); + Integer stageNumber = NonPeakStages.stageNumberFromId(currentStage == null ? null : currentStage.animationId()); + if (stageNumber != null && stageNumber == 2) { + long cycleTicks = Math.max(1L, currentStage.lengthTicks()); + long elapsedTicks = elapsedWorldTicksFromAnimationTicks(cycleTicks, runtime.stageSpeed()); + long routeTick = runtime.stageStartTick() + Math.max(1L, elapsedTicks); + if (nowTick >= routeTick) { + int targetStage = hatchState.remainingSpawns() > 1 ? 2 : 3; + AnimationService.advanceToStage(world, instanceId, targetStage); + } + } + } + } + } + + private static void handlePregnancyBirthCue(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + NeedsOfNature.PregnancyHatchState hatchState = PREGNANCY_HATCH_BY_INSTANCE.get(instanceId); + if (hatchState != null && hatchState.remainingSpawns() > 0) { + ServerPlayerEntity player = findPlayerOnWorldServer(world, hatchState.playerUuid()); + if (player != null && player.getEntityWorld() == world) { + int spawnNow = 1; + NeedsOfNature.PregnancyVariantData variantData = hatchState.sourceEntityTypeId().equals(hatchState.birthEntityTypeId()) + ? hatchState.variantData() + : null; + int spawned = spawnPregnancyOutcome( + world, player, hatchState.sourceEntityTypeId(), hatchState.birthEntityTypeId(), hatchState.birthMode(), spawnNow, variantData + ); + if (spawned > 0) { + NonStats.addOffspringSpawned(player, hatchState.birthEntityTypeId(), spawned); + } + + NeedsOfNature.PregnancyHatchState updated = hatchState.withProgress(hatchState.remainingSpawns() - spawnNow, hatchState.spawnedCount() + spawned); + if (updated.remainingSpawns() <= 0) { + PREGNANCY_HATCH_BY_INSTANCE.remove(instanceId); + finishPregnancyHatch(world, instanceId, player, updated); + } else { + PREGNANCY_HATCH_BY_INSTANCE.put(instanceId, updated); + } + } + } + } + } + + private static void finalizePregnancyHatch(ServerWorld world, UUID instanceId, boolean cueTriggered) { + if (world != null && instanceId != null) { + NeedsOfNature.PregnancyHatchState hatchState = PREGNANCY_HATCH_BY_INSTANCE.remove(instanceId); + NeedsOfNature.PregnancyCueRuntime cueRuntime = PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(instanceId); + if (hatchState != null) { + ServerPlayerEntity player = findPlayerOnWorldServer(world, hatchState.playerUuid()); + if (player != null && player.getEntityWorld() == world) { + if (!cueTriggered) { + String clip = cueRuntime != null && cueRuntime.clipId() != null ? cueRuntime.clipId().toString() : "unknown"; + LOGGER.warn( + "[NoN] Birth fallback spawn on STOP for instance {} (clip={}, cue={}).", + new Object[]{instanceId, clip, cueRuntime == null ? "none" : String.valueOf(cueRuntime.cueTick())} + ); + sendDebugChat(player, NonDebugChatCategory.SETUP, Text.translatable("debug.needsofnature.birth_fallback_spawn", new Object[]{clip})); + } + + int remaining = Math.max(0, hatchState.remainingSpawns()); + NeedsOfNature.PregnancyVariantData variantData = hatchState.sourceEntityTypeId().equals(hatchState.birthEntityTypeId()) + ? hatchState.variantData() + : null; + int spawned = remaining > 0 + ? spawnPregnancyOutcome( + world, player, hatchState.sourceEntityTypeId(), hatchState.birthEntityTypeId(), hatchState.birthMode(), remaining, variantData + ) + : 0; + if (spawned > 0) { + NonStats.addOffspringSpawned(player, hatchState.birthEntityTypeId(), spawned); + } + + finishPregnancyHatch(world, instanceId, player, hatchState.withProgress(0, hatchState.spawnedCount() + spawned)); + } + } + } + } + + private static void finishPregnancyHatch(ServerWorld world, UUID instanceId, ServerPlayerEntity player, NeedsOfNature.PregnancyHatchState hatchState) { + if (world != null && instanceId != null && player != null && hatchState != null) { + PREGNANCY_HATCH_BY_INSTANCE.remove(instanceId); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(instanceId); + PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.remove(hatchState.playerUuid(), instanceId); + PREGNANCY_QUEUED_PLAYERS.remove(hatchState.playerUuid()); + NonStats.incrementPregnancyCompleted(player); + NonAdvancementHooks.checkMotherNature(player); + if (hatchState.spawnedCount() > 1) { + NonAdvancementHooks.grantProductivePregnancy(player); + } + + clearPregnancyState(player, true); + } + } + + private static int spawnPregnancyOutcome( + ServerWorld world, + ServerPlayerEntity player, + Identifier sourceEntityTypeId, + Identifier entityTypeId, + String birthMode, + int spawnCount, + @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + if (world == null || player == null || entityTypeId == null || spawnCount <= 0) { + return 0; + } else { + return "egg".equals(NonConfig.normalizeBirthMode(birthMode)) + ? spawnPregnancyEggs(world, player, sourceEntityTypeId, entityTypeId, spawnCount, variantData) + : spawnPregnancyOffspring(world, player, entityTypeId, spawnCount, variantData); + } + } + + private static int spawnPregnancyEggs( + ServerWorld world, + ServerPlayerEntity player, + Identifier sourceEntityTypeId, + Identifier entityTypeId, + int spawnCount, + @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + if (world != null && player != null && entityTypeId != null && spawnCount > 0) { + Identifier eggSourceEntityId = sourceEntityTypeId == null ? entityTypeId : sourceEntityTypeId; + NonConfig.EggProfile eggProfile = NonEntityProfiles.resolve(eggSourceEntityId).eggProfile(); + int hatchSeconds = getConfig().getPregnancyEggHatchSecondsFor(eggSourceEntityId.toString()); + long hatchAtTick = getGlobalTick(world) + Math.max(1, hatchSeconds) * 20L; + int spawned = 0; + + for (int i = 0; i < spawnCount; i++) { + PregnancyEggEntity egg = (PregnancyEggEntity)PREGNANCY_EGG_ENTITY_TYPE.create(world, SpawnReason.BREEDING); + if (egg != null) { + float yaw = world.getRandom().nextFloat() * 360.0F; + Vec3d spawnOffset = randomPregnancyEggSpawnOffset(world, spawnCount); + egg.refreshPositionAndAngles(player.getX() + spawnOffset.x, player.getY(), player.getZ() + spawnOffset.z, yaw, 0.0F); + egg.configure(player.getUuid(), eggSourceEntityId, entityTypeId, hatchAtTick, variantData, eggProfile); + if (world.spawnEntity(egg)) { + spawned++; + } + } + } + + return spawned; + } else { + return 0; + } + } + + private static Vec3d randomPregnancyEggSpawnOffset(ServerWorld world, int spawnCount) { + double angle = world.getRandom().nextDouble() * Math.PI * 2.0; + double radius = 0.08 + world.getRandom().nextDouble() * 0.1; + return new Vec3d(Math.cos(angle) * radius, 0.0, Math.sin(angle) * radius); + } + + private static int spawnPregnancyOffspring( + ServerWorld world, ServerPlayerEntity player, Identifier entityTypeId, int spawnCount, @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + if (world != null && player != null && entityTypeId != null && spawnCount > 0) { + if (!Registries.ENTITY_TYPE.containsId(entityTypeId)) { + LOGGER.warn("[NoN] Cannot spawn pregnancy offspring: unknown entity type {}.", entityTypeId); + sendDebugChat( + player, + NonDebugChatCategory.SETUP, + Text.translatable("debug.needsofnature.missing_pregnancy_entity_type", new Object[]{entityTypeId.toString()}) + ); + return 0; + } + + EntityType entityType = (EntityType)Registries.ENTITY_TYPE.get(entityTypeId); + int spawned = 0; + + for (int i = 0; i < spawnCount; i++) { + Entity entity = entityType.create(world, SpawnReason.BREEDING); + if (entity != null) { + entity.refreshPositionAndAngles(player.getX(), player.getY(), player.getZ(), player.getYaw(), player.getPitch()); + if (entity instanceof MobEntity mob) { + mob.initialize(world, world.getLocalDifficulty(mob.getBlockPos()), SpawnReason.BREEDING, null); + applyPregnancyVariantData(world, mob, variantData); + applyPregnancyMobFlags(mob, player); + } + + if (world.spawnEntity(entity)) { + spawned++; + } + } + } + + return spawned; + } else { + return 0; + } + } + + private static void applyPregnancyMobFlags(MobEntity mob, ServerPlayerEntity player) { + applyPregnancyMobFlagsForEgg(mob, player); + } + + public static void applyPregnancyMobFlagsForEgg(MobEntity mob, @Nullable ServerPlayerEntity player) { + if (mob != null) { + mob.setPersistent(); + if (mob instanceof PassiveEntity passive) { + passive.setBreedingAge(-24000); + } else { + tryInvokeBooleanSetter(mob, "setBaby"); + } + + if (player != null && getConfig().isPregnancyAutoTameMobs()) { + if (mob instanceof TameableEntity tameable) { + tameable.setTamedBy(player); + tameable.setTarget(null); + } else if (mob instanceof AbstractHorseEntity horse) { + horse.bondWithPlayer(player); + horse.setOwner(player); + } else { + tryInvokeBooleanSetter(mob, "setTamed"); + tryInvokeBooleanSetter(mob, "setTame"); + tryInvokeSingleArg(mob, "setOwner", PlayerEntity.class, player); + tryInvokeSingleArg(mob, "setOwner", ServerPlayerEntity.class, player); + tryInvokeSingleArg(mob, "setOwnerUuid", UUID.class, player.getUuid()); + tryInvokeSingleArg(mob, "bondWithPlayer", PlayerEntity.class, player); + tryInvokeSingleArg(mob, "bondWithPlayer", ServerPlayerEntity.class, player); + } + } + + if (getConfig().pregnancyFriendlyMobsIgnorePlayers()) { + if (mob instanceof Angerable angerable) { + angerable.stopAnger(); + } + + if (mob.getTarget() instanceof ServerPlayerEntity) { + mob.setTarget(null); + } + + mob.addCommandTag("non.pregnancy_friendly"); + } + } + } + + private static void tryInvokeBooleanSetter(Object target, String methodName) { + if (target != null && methodName != null && !methodName.isBlank()) { + Method method = findMethod(target.getClass(), methodName, boolean.class); + if (method != null) { + tryInvokeMethod(target, method, true); + } + } + } + + private static void tryInvokeSingleArg(Object target, String methodName, Class argType, Object argValue) { + if (target != null && methodName != null && !methodName.isBlank() && argType != null) { + Method method = findMethod(target.getClass(), methodName, argType); + if (method != null) { + tryInvokeMethod(target, method, argValue); + } + } + } + + private static Method findMethod(Class type, String methodName, Class... parameterTypes) { + for (Class cursor = type; cursor != null; cursor = cursor.getSuperclass()) { + Method declared = findDeclaredMethod(cursor, methodName, parameterTypes); + if (declared != null) { + return declared; + } + } + + return findPublicMethod(Objects.requireNonNull(type), methodName, parameterTypes); + } + + private static Method findDeclaredMethod(Class owner, String methodName, Class... parameterTypes) { + Method[] methods = owner.getDeclaredMethods(); + + for (Method method : methods) { + if (method.getName().equals(methodName) && parameterTypesEqual(method.getParameterTypes(), parameterTypes)) { + try { + method.setAccessible(true); + return method; + } catch (RuntimeException e) { + logReflectionFailureOnce("findDeclared:" + owner.getName() + "#" + methodName, e); + return null; + } + } + } + + return null; + } + + private static Method findPublicMethod(Class owner, String methodName, Class... parameterTypes) { + Method[] methods = owner.getMethods(); + + for (Method method : methods) { + if (method.getName().equals(methodName) && parameterTypesEqual(method.getParameterTypes(), parameterTypes)) { + try { + method.setAccessible(true); + return method; + } catch (RuntimeException e) { + logReflectionFailureOnce("findPublic:" + owner.getName() + "#" + methodName, e); + return null; + } + } + } + + return null; + } + + private static boolean parameterTypesEqual(Class[] left, Class[] right) { + if (left.length != right.length) { + return false; + } + + for (int i = 0; i < left.length; i++) { + if (!left[i].equals(right[i])) { + return false; + } + } + + return true; + } + + private static void tryInvokeMethod(Object target, Method method, Object... args) { + if (target != null && method != null) { + try { + method.invoke(target, args); + } catch (ReflectiveOperationException | RuntimeException e) { + logReflectionFailureOnce("invoke:" + method.toGenericString(), e); + } + } + } + + private static void logReflectionFailureOnce(String key, Throwable error) { + if (key != null && REFLECTION_FAILURE_LOGS.add(key)) { + LOGGER.debug("[NoN] Reflection helper failure at {}", key, error); + } + } + + public static boolean isPregnancyFriendlyMob(MobEntity mob) { + return mob != null && getConfig().pregnancyFriendlyMobsIgnorePlayers() && mob.getCommandTags().contains("non.pregnancy_friendly"); + } + + static long getGlobalTick(ServerWorld world) { + if (world != null && world.getServer() != null) { + ServerWorld overworld = world.getServer().getOverworld(); + return overworld == null ? world.getTime() : overworld.getTime(); + } else { + return 0L; + } + } + + private static PregnancyHolder getPregnancyHolder(ServerPlayerEntity player) { + return player instanceof PregnancyHolder holder ? holder : null; + } + + static Identifier getPregnantEntityTypeId(ServerPlayerEntity player) { + PregnancyHolder holder = getPregnancyHolder(player); + return holder == null ? null : holder.getPregnantEntityTypeId(); + } + + static void beginPregnancy(ServerPlayerEntity player, Identifier entityTypeId, long endTick) { + beginPregnancy(player, entityTypeId, endTick, null); + } + + static void beginPregnancy(ServerPlayerEntity player, Identifier entityTypeId, long endTick, @Nullable NeedsOfNature.PregnancyVariantData variantData) { + beginPregnancy(player, entityTypeId, endTick, variantData, 0); + } + + static void beginPregnancy( + ServerPlayerEntity player, Identifier entityTypeId, long endTick, @Nullable NeedsOfNature.PregnancyVariantData variantData, int offspringCountOverride + ) { + if (player != null && entityTypeId != null) { + if (NonGenderSystem.isOnlyMale(player)) { + clearPregnancyState(player, true); + } else if (((NonEvents.AllowPregnancy)NonEvents.ALLOW_PREGNANCY.invoker()).allowPregnancy(player, entityTypeId)) { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null) { + int offspringCount = rollPregnancyOffspringCount(player, entityTypeId, offspringCountOverride); + holder.setPregnancyState(entityTypeId, Math.max(0L, endTick), false, variantData); + holder.setPregnancyOffspringCount(offspringCount); + syncPregnancyState(player); + NonApiInternals.firePregnancyChanged(player); + PREGNANCY_START_RETRY_TICKS.remove(player.getUuid()); + PREGNANCY_QUEUED_PLAYERS.remove(player.getUuid()); + UUID active = PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.remove(player.getUuid()); + if (active != null) { + PREGNANCY_HATCH_BY_INSTANCE.remove(active); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(active); + } + + long nowTick = player.getEntityWorld() instanceof ServerWorld world ? getGlobalTick(world) : 0L; + long remaining = Math.max(1L, endTick - nowTick); + int duration = (int)Math.min(2147483647L, remaining); + player.addStatusEffect(new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT), duration, 0, false, false, true)); + } + } + } + } + + static void markPregnancyPending(ServerPlayerEntity player, long nowTick) { + if (player != null) { + if (NonGenderSystem.isOnlyMale(player)) { + clearPregnancyState(player, true); + } else { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null) { + Identifier entityTypeId = holder.getPregnantEntityTypeId(); + if (entityTypeId != null) { + holder.setPregnancyState(entityTypeId, Math.max(0L, nowTick), true, holder.getPregnancyVariantData()); + refreshPendingPregnancyEffect(player); + NonApiInternals.firePregnancyChanged(player); + } + } + } + } + } + + private static void refreshPendingPregnancyEffect(ServerPlayerEntity player) { + if (player != null) { + RegistryEntry effect = Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT); + StatusEffectInstance current = player.getStatusEffect(effect); + if (current == null || current.getDuration() < 21) { + player.addStatusEffect(new StatusEffectInstance(effect, 21, 0, false, false, true)); + } + } + } + + @Nullable + private static ServerPlayerEntity findPlayerOnWorldServer(ServerWorld world, UUID playerUuid) { + if (world != null && playerUuid != null) { + MinecraftServer server = world.getServer(); + return server == null ? null : server.getPlayerManager().getPlayer(playerUuid); + } else { + return null; + } + } + + static void clearPregnancyState(ServerPlayerEntity player, boolean removeEffect) { + if (player != null) { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null) { + holder.clearPregnancyState(); + } + + clearPregnancyRuntime(player.getUuid()); + if (removeEffect) { + player.removeStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT)); + } + + syncPregnancyState(player); + NonApiInternals.firePregnancyChanged(player); + } + } + + private static void clearPregnancyRuntime(UUID playerUuid) { + if (playerUuid != null) { + PREGNANCY_START_RETRY_TICKS.remove(playerUuid); + PREGNANCY_QUEUED_PLAYERS.remove(playerUuid); + UUID active = PREGNANCY_ACTIVE_INSTANCE_BY_PLAYER.remove(playerUuid); + if (active != null) { + PREGNANCY_HATCH_BY_INSTANCE.remove(active); + PREGNANCY_CUE_RUNTIME_BY_INSTANCE.remove(active); + } + } + } + + private static void refreshPregnancyAfterDeathCopy(ServerPlayerEntity player) { + PregnancyHolder holder = getPregnancyHolder(player); + if (holder != null && holder.getPregnantEntityTypeId() != null) { + long nowTick = player.getEntityWorld() instanceof ServerWorld world ? getGlobalTick(world) : 0L; + if (!holder.isPregnancyPendingHatch() && holder.getPregnancyEndTick() > nowTick) { + long remaining = Math.max(1L, holder.getPregnancyEndTick() - nowTick); + int duration = (int)Math.min(2147483647L, remaining); + player.addStatusEffect(new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT), duration, 0, false, false, true)); + } else { + markPregnancyPending(player, nowTick); + } + } + } + + public static void clearPregnancyOnDeath(ServerPlayerEntity player) { + clearPregnancyState(player, true); + } + + private void registerAttackStopRequests() { + ServerPlayNetworking.registerGlobalReceiver(StopAttackAnimationC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (player.getEntityWorld() instanceof ServerWorld world) { + UUID instanceId = payload.instanceId(); + if (!NonPlayerAttackSystem.isContest(instanceId)) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (isAttackSnapshot(snapshot) && snapshot.actorUuids().contains(player.getUuid())) { + completeAttackEscape(world, player, instanceId, null); + } + } + } + })); + } + + static void completeAttackEscape(ServerWorld world, ServerPlayerEntity player, UUID instanceId, @Nullable UUID preferredAttackerUuid) { + if (world != null && player != null && instanceId != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (isAttackSnapshot(snapshot) && snapshot.actorUuids().contains(player.getUuid())) { + List actors = snapshot.actorUuids(); + boolean defeatedInstance = isDefeatedInstance(world, instanceId); + int escapeInvulnerabilityTicks = getConfig().getAttackEscapeInvulnerabilityTicks(); + int escapeAnimationProtectionTicks = getConfig().getAttackEscapeAnimationProtectionTicks(); + if (defeatedInstance && escapeInvulnerabilityTicks > 0) { + setPostEscapeDamageInvulnerable(player, escapeInvulnerabilityTicks); + } + + setAnimationStartProtection(player, escapeAnimationProtectionTicks); + NeedsOfNature.PeakState peakState = PEAK_STATES.get(instanceId); + boolean allowReattack = peakState == null || !peakState.peaked; + clearQueuedAnimations(world, player); + if (!defeatedInstance) { + scheduleAttackEscapeResolution(world, player, actors, preferredAttackerUuid, allowReattack, escapeInvulnerabilityTicks); + NonStats.incrementAttackEscapeSuccess(player); + NonAdvancementHooks.resetTossedAround(player); + } + + AnimationService.stopAnimation(world, instanceId); + if (defeatedInstance && allowReattack) { + requestOriginalAttackers(world, player, actors); + } + } + } + } + + private record ActiveBreedingState(RegistryKey worldKey, UUID firstUuid, UUID secondUuid) { + } + + public enum AttackBlockReason { + PREGNANCY_FRIENDLY, + GATHERING_PAIR, + ENERGY_ABOVE_70_PLAYER_BUSY; + } + + private record BreedingPairKey(UUID firstUuid, UUID secondUuid) { + } + + private record FillBottleState(UUID playerUuid, boolean reservedBottle) { + } + + private record FinitePulseSchedule(List ticks, List amounts) { + } + + private record GatherPairKey(UUID mobUuid, UUID playerUuid) { + } + + private record GatherPairState(RegistryKey worldKey, long expiresTick) { + } + + private record LastDefeatedCandidate(MobEntity mob, NonGatherSystem.GatherCandidate gatherCandidate, AnimationService.MatchedAnimation defeatMatch) { + } + + private record LiquidGainProfile( + int amount, + int xpEligibleAmount, + LiquidHolder.LiquidComposition composition, + Identifier entityTypeId, + Map entityAmounts, + @Nullable NeedsOfNature.PregnancyVariantData donorVariantData, + List recentDonors + ) { + LiquidGainProfile { + entityAmounts = entityAmounts != null && !entityAmounts.isEmpty() ? Map.copyOf(entityAmounts) : Map.of(); + recentDonors = recentDonors != null && !recentDonors.isEmpty() ? List.copyOf(recentDonors) : List.of(); + } + } + + private record LiquidPulseResult(int addedMl, int xpEligibleAddedMl) { + private static final NeedsOfNature.LiquidPulseResult EMPTY = new NeedsOfNature.LiquidPulseResult(0, 0); + } + + private record LiquidSnapshot( + int stored, + LiquidHolder.LiquidComposition composition, + @Nullable Identifier entityTypeId, + @Nullable NeedsOfNature.PregnancyVariantData donorVariantData, + List recentDonors + ) { + static void copy(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + if (oldPlayer instanceof LiquidHolder oldHolder && newPlayer instanceof LiquidHolder newHolder) { + NeedsOfNature.LiquidSnapshot snapshot = new NeedsOfNature.LiquidSnapshot( + oldHolder.getLiquidStored(), + oldHolder.getLiquidComposition(), + oldHolder.getLiquidEntityTypeId(), + oldHolder.getLiquidDonorVariantData(), + oldHolder.getRecentLiquidDonors() + ); + newHolder.setLiquidStored(snapshot.stored()); + if (newHolder.getLiquidStored() <= 0) { + newHolder.clearLiquidComposition(); + } else { + if (snapshot.composition() == LiquidHolder.LiquidComposition.ENTITY && snapshot.entityTypeId() != null) { + newHolder.setLiquidCompositionEntity(snapshot.entityTypeId()); + newHolder.setLiquidDonorVariantData(snapshot.donorVariantData()); + } else if (snapshot.composition() == LiquidHolder.LiquidComposition.MIXED) { + newHolder.setLiquidCompositionMixed(); + } else { + newHolder.clearLiquidComposition(); + } + + newHolder.setRecentLiquidDonors(snapshot.recentDonors()); + } + } + } + } + + private record MessSnapshot(int v, int a, int m) { + static void copy(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + if (oldPlayer instanceof MessHolder oldHolder && newPlayer instanceof MessHolder newHolder) { + NeedsOfNature.MessSnapshot snapshot = new NeedsOfNature.MessSnapshot(oldHolder.getVMess(), oldHolder.getAMess(), oldHolder.getMMess()); + newHolder.setVMess(snapshot.v()); + newHolder.setAMess(snapshot.a()); + newHolder.setMMess(snapshot.m()); + } + } + } + + private record NoNGatherActorState(RegistryKey worldKey, long expiresTick) { + } + + private record PeakLiquidPlan( + Map gainsByReceiver, + Map> accessoryPregnancyDonors, + Set suppliedInjectorRoles + ) { + PeakLiquidPlan { + gainsByReceiver = gainsByReceiver != null && !gainsByReceiver.isEmpty() ? Map.copyOf(gainsByReceiver) : Map.of(); + if (accessoryPregnancyDonors != null && !accessoryPregnancyDonors.isEmpty()) { + Map> frozen = new LinkedHashMap<>(); + accessoryPregnancyDonors.forEach((uuid, donors) -> { + if (uuid != null && donors != null && !donors.isEmpty()) { + frozen.put(uuid, List.copyOf((Collection)donors)); + } + }); + accessoryPregnancyDonors = Map.copyOf(frozen); + } else { + accessoryPregnancyDonors = Map.of(); + } + + suppliedInjectorRoles = suppliedInjectorRoles != null && !suppliedInjectorRoles.isEmpty() ? Set.copyOf(suppliedInjectorRoles) : Set.of(); + } + + int maxAmount() { + int max = 0; + + for (NeedsOfNature.LiquidGainProfile gain : this.gainsByReceiver.values()) { + if (gain != null) { + max = Math.max(max, gain.amount()); + } + } + + return max; + } + } + + private static final class PeakLiquidProgressState { + final RegistryKey worldKey; + final NeedsOfNature.PeakLiquidPlan plan; + final List scheduledTicks; + final List scheduledAmounts; + int scheduleIndex; + final boolean infinite; + final double cycleTicks; + final List cueOffsets; + final long stageStartTick; + final double stageSpeed; + long cycleIndex; + int cueIndex; + int distributedBaseMl; + int remainingBaseMl; + final Set donorHistoryRecordedReceivers = new HashSet<>(); + + private PeakLiquidProgressState( + RegistryKey worldKey, + NeedsOfNature.PeakLiquidPlan plan, + List scheduledTicks, + List scheduledAmounts, + boolean infinite, + double cycleTicks, + List cueOffsets, + long stageStartTick, + double stageSpeed, + int remainingBaseMl + ) { + this.worldKey = worldKey; + this.plan = plan; + this.scheduledTicks = scheduledTicks; + this.scheduledAmounts = scheduledAmounts; + this.scheduleIndex = 0; + this.infinite = infinite; + this.cycleTicks = cycleTicks; + this.cueOffsets = cueOffsets; + this.stageStartTick = stageStartTick; + this.stageSpeed = stageSpeed; + this.cycleIndex = 0L; + this.cueIndex = 0; + this.distributedBaseMl = 0; + this.remainingBaseMl = remainingBaseMl; + } + + static NeedsOfNature.PeakLiquidProgressState finite( + RegistryKey worldKey, NeedsOfNature.PeakLiquidPlan plan, List scheduledTicks, List scheduledAmounts + ) { + return new NeedsOfNature.PeakLiquidProgressState(worldKey, plan, scheduledTicks, scheduledAmounts, false, 0.0, List.of(), 0L, 1.0, 0); + } + + static NeedsOfNature.PeakLiquidProgressState infinite( + RegistryKey worldKey, NeedsOfNature.PeakLiquidPlan plan, double cycleTicks, List cueOffsets, long stageStartTick, double stageSpeed + ) { + return new NeedsOfNature.PeakLiquidProgressState( + worldKey, plan, List.of(), List.of(), true, cycleTicks, cueOffsets, stageStartTick, stageSpeed, plan.maxAmount() + ); + } + } + + private record PeakState(Identifier animationId, int peakStage, boolean peaked, int lastStage, boolean hasPlayer, boolean liquidGranted, int actualAddedMl) { + } + + private record PendingAttackEscapeResolution( + RegistryKey worldKey, List actorUuids, @Nullable UUID preferredAttackerUuid, long dueTick, boolean allowReattack, int invulnerabilityTicks + ) { + } + + private record PendingBreedingState(RegistryKey worldKey, UUID firstUuid, UUID secondUuid, long expiresTick) { + } + + private record PendingMInjectorReward(RegistryKey worldKey, long freeSinceTick) { + } + + public record PlayerEnergyAuraDebug( + ServerPlayerEntity player, double totalMultiplier, int playerEnergy, int destroyedSkinStage, int messTotal, double radius + ) { + } + + private record PostDeathStateSync(RegistryKey worldKey, long dueTick) { + } + + private record PregnancyCueRuntime( + RegistryKey worldKey, long stageStartTick, Identifier clipId, Integer cueTick, boolean cueTriggered, double stageSpeed + ) { + } + + record PregnancyDonor(Identifier entityTypeId, @Nullable NeedsOfNature.PregnancyVariantData variantData) { + } + + private record PregnancyHatchState( + UUID playerUuid, + Identifier sourceEntityTypeId, + Identifier birthEntityTypeId, + String birthMode, + int totalSpawnCount, + int remainingSpawns, + int spawnedCount, + @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + NeedsOfNature.PregnancyHatchState withProgress(int newRemainingSpawns, int newSpawnedCount) { + return new NeedsOfNature.PregnancyHatchState( + this.playerUuid, + this.sourceEntityTypeId, + this.birthEntityTypeId, + this.birthMode, + this.totalSpawnCount, + Math.max(0, newRemainingSpawns), + Math.max(0, newSpawnedCount), + this.variantData + ); + } + } + + private record PregnancySnapshot( + Identifier entityTypeId, long endTick, boolean pendingHatch, @Nullable NeedsOfNature.PregnancyVariantData variantData, int offspringCount + ) { + static void copy(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + PregnancyHolder oldHolder = NeedsOfNature.getPregnancyHolder(oldPlayer); + PregnancyHolder newHolder = NeedsOfNature.getPregnancyHolder(newPlayer); + if (oldHolder != null && newHolder != null && oldHolder.getPregnantEntityTypeId() != null) { + if (NonGenderSystem.isOnlyMale(newPlayer)) { + NeedsOfNature.clearPregnancyState(newPlayer, true); + } else { + NeedsOfNature.PregnancySnapshot snapshot = new NeedsOfNature.PregnancySnapshot( + oldHolder.getPregnantEntityTypeId(), + oldHolder.getPregnancyEndTick(), + oldHolder.isPregnancyPendingHatch(), + oldHolder.getPregnancyVariantData(), + oldHolder.getPregnancyOffspringCount() + ); + NeedsOfNature.clearPregnancyRuntime(newPlayer.getUuid()); + newHolder.setPregnancyState(snapshot.entityTypeId(), snapshot.endTick(), snapshot.pendingHatch(), snapshot.variantData()); + newHolder.setPregnancyOffspringCount(snapshot.offspringCount()); + NeedsOfNature.refreshPregnancyAfterDeathCopy(newPlayer); + NeedsOfNature.syncPregnancyState(newPlayer); + NonApiInternals.firePregnancyChanged(newPlayer); + } + } + } + } + + public record PregnancyVariantData(Map components, @Nullable Integer horseMarkingIndex) { + public PregnancyVariantData { + components = components != null && !components.isEmpty() ? Map.copyOf(components) : Map.of(); + } + + public boolean isEmpty() { + return this.components.isEmpty() && this.horseMarkingIndex == null; + } + } + + private record ReceiverDripState(RegistryKey worldKey, long nextTick, long endTick, int tintRgb) { + } + + private record RippedSkinSnapshot(int stage, int damage) { + static void copy(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + if (oldPlayer instanceof DestroyedSkinHolder oldHolder && newPlayer instanceof DestroyedSkinHolder newHolder) { + NeedsOfNature.RippedSkinSnapshot snapshot = new NeedsOfNature.RippedSkinSnapshot( + oldHolder.getDestroyedSkinStage(), oldHolder.getDestroyedSkinDamage() + ); + newHolder.setDestroyedSkinDamage(snapshot.damage()); + NonDestroyedSkinSystem.setDestroyedSkinStage(newPlayer, snapshot.stage()); + } + } + } + + private record ScanBudget(long tick, int remaining) { + } + + private interface VariantComponentDescriptor { + String key(); + + void capture(ServerWorld var1, Entity var2, Map var3); + + void apply(ServerWorld var1, Entity var2, String var3); + } +} diff --git a/src/main/java/com/nonid/NeedsOfNatureClient.java b/src/main/java/com/nonid/NeedsOfNatureClient.java new file mode 100644 index 0000000..6ba0ad6 --- /dev/null +++ b/src/main/java/com/nonid/NeedsOfNatureClient.java @@ -0,0 +1,1521 @@ +package com.nonid; + +import com.google.gson.Gson; +import com.nonid.api.client.NonAnimationParticleEvents; +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.api.client.NonAnimationSoundEvents; +import com.nonid.client.HorseLiquidCollectorGeoModel; +import com.nonid.client.HorseLiquidCollectorRenderer; +import com.nonid.client.NonAccessoryTooltips; +import com.nonid.client.NonArmorShedClient; +import com.nonid.client.NonClientDiagnostics; +import com.nonid.client.NonClientResourceCaches; +import com.nonid.client.NonDebugSpinMode; +import com.nonid.client.NonDebugSpinMusicClient; +import com.nonid.client.NonDestroyedSkinClient; +import com.nonid.client.NonGenderSelectionScreen; +import com.nonid.client.NonHudOverlay; +import com.nonid.client.NonInventoryScreenHooks; +import com.nonid.client.NonPackStartupWarnings; +import com.nonid.client.NonPlayerInteractionClient; +import com.nonid.client.NonPregnancyClientState; +import com.nonid.client.NonServerSettingsClient; +import com.nonid.client.NonSoundCues; +import com.nonid.client.NonSpinModelLock; +import com.nonid.client.NonWildfireGenderScreenHooks; +import com.nonid.client.NonWildfireGenderSync; +import com.nonid.client.PregnancyEggRenderer; +import com.nonid.client.intiface.NonIntifaceBridge; +import com.nonid.client.particle.LiquidFallingParticle; +import com.nonid.client.particle.LiquidParticle; +import com.nonid.client.particle.LiquidPuddleParticle; +import com.nonid.client.particle.LiquidWaterParticle; +import com.nonid.client.particle.RippedFabricParticle; +import com.nonid.client.particle.SmallHeartParticle; +import com.nonid.data.NonAccessoryItemRegistry; +import com.nonid.data.NonPeakStages; +import com.nonid.entity.HorseLiquidCollectorEntity; +import com.nonid.integration.NonCreditsButtonHooks; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.AnimationEngineClient; +import com.nonid.internal.animation.client.render.gecko.GeckoResourceResolver; +import com.nonid.internal.animation.client.render.gecko.GenderedModelResolver; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import com.nonid.internal.animation.client.sound.AnimationSoundAnticipation; +import com.nonid.internal.animation.data.AnimationCueEffects; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.mixin.client.EntityRenderManagerAccessor; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.AnimationIds; +import com.nonid.network.AccessoryShedStateS2CPayload; +import com.nonid.network.AnimationCatalogChunkS2CPayload; +import com.nonid.network.AttackStateS2CPayload; +import com.nonid.network.DestroyedSkinMaskSyncS2CPayload; +import com.nonid.network.DestroyedSkinParticlesS2CPayload; +import com.nonid.network.DestroyedSkinStateS2CPayload; +import com.nonid.network.DestroyedSkinSyncS2CPayload; +import com.nonid.network.GenderSelectionPromptS2CPayload; +import com.nonid.network.HostConfigSyncData; +import com.nonid.network.HostConfigSyncS2CPayload; +import com.nonid.network.LiquidStateS2CPayload; +import com.nonid.network.MessStateS2CPayload; +import com.nonid.network.PlayerAttackInputC2SPayload; +import com.nonid.network.PlayerRuntimeSettingsS2CPayload; +import com.nonid.network.PregnancyCycleStateS2CPayload; +import com.nonid.network.PregnancyStateS2CPayload; +import com.nonid.network.ServerConfigResultS2CPayload; +import com.nonid.network.SetPlayerGenderC2SPayload; +import com.nonid.network.SkinRippedInfoRequestS2CPayload; +import com.nonid.network.StartManualPeakC2SPayload; +import com.nonid.network.StopAttackAnimationC2SPayload; +import com.nonid.network.StopVoluntaryAnimationC2SPayload; +import com.nonid.particle.NonParticles; +import com.nonid.potion.NonPotions; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.ClientStarted; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents.EndTick; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.Disconnect; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.Join; +import net.fabricmc.fabric.api.client.particle.v1.ParticleFactoryRegistry; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.option.KeyBinding.Category; +import net.minecraft.client.render.entity.EntityRendererFactories; +import net.minecraft.client.render.entity.equipment.EquipmentModel; +import net.minecraft.client.render.entity.equipment.EquipmentModelLoader; +import net.minecraft.client.render.entity.equipment.EquipmentModel.Dyeable; +import net.minecraft.client.render.entity.equipment.EquipmentModel.Layer; +import net.minecraft.client.render.entity.equipment.EquipmentModel.LayerType; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.client.util.InputUtil.Type; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.DyedColorComponent; +import net.minecraft.component.type.EquippableComponent; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.EquipmentSlot; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.passive.AbstractDonkeyEntity; +import net.minecraft.entity.passive.AbstractHorseEntity; +import net.minecraft.entity.passive.HorseEntity; +import net.minecraft.entity.passive.HorseMarking; +import net.minecraft.entity.passive.SheepEntity; +import net.minecraft.entity.passive.WolfEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerModelPart; +import net.minecraft.entity.player.SkinTextures; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.item.equipment.EquipmentAsset; +import net.minecraft.item.equipment.EquipmentAssetKeys; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryKey; +import net.minecraft.resource.Resource; +import net.minecraft.util.DyeColor; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; + +public class NeedsOfNatureClient implements ClientModInitializer { + private static final Gson HOST_CONFIG_SYNC_GSON = new Gson(); + private static final Identifier WOLF_COLLAR_TEXTURE = Identifier.ofVanilla("textures/entity/wolf/wolf_collar.png"); + private static final Identifier SHEEP_WOOL_TEXTURE = Identifier.ofVanilla("textures/entity/sheep/sheep_wool.png"); + private static final Identifier SHEEP_WOOL_UNDERCOAT_TEXTURE = Identifier.ofVanilla("textures/entity/sheep/sheep_wool_undercoat.png"); + private static final Identifier FILL_BOTTLE_ANIMATION_ID = Identifier.of("needsofnature", "player_fill_bottle"); + private static final String FILL_BOTTLE_PROP_CUE_EFFECT = "fillbottle_prop_fill"; + private static final Identifier VANILLA_BOTTLE_FILL_SOUND = Identifier.ofVanilla("item.bottle.fill"); + private static final String RIGHT_PROP_BONE = "propright"; + private static final float REACTIVE_IMPACT_FOV_DELTA = 0.5F; + private static final float REACTIVE_IMPACT_HOLD_TICKS = 3.0F; + private static final float REACTIVE_IMPACT_RECOVER_TICKS = 14.0F; + private static final double NANOS_PER_CLIENT_TICK = 5.0E7; + private static final int INTIFACE_BIRTH_INTRO_STAGE = 1; + private static final int INTIFACE_BIRTH_LOOP_STAGE = 2; + private static final int INTIFACE_BIRTH_FINAL_STAGE = 3; + private static final double INTIFACE_BIRTH_LOOP_START_SCALAR = 0.6; + private static final Set INVALID_PARTICLE_COLOR_SOURCES_WARNED = new HashSet<>(); + private static long CLIENT_FALLBACK_TICKS = 0L; + private static volatile long REACTIVE_IMPACT_PULSE_START_NANOS = Long.MIN_VALUE; + private static int LAST_LOCAL_GENDER_SYNC_MASK = 0; + private static final Map WOLF_COLLAR_TINT_CACHE = new HashMap<>(); + private static final Map SHEEP_WOOL_TINT_CACHE = new HashMap<>(); + private static final Map SHEEP_WOOL_UNDERCOAT_TINT_CACHE = new HashMap<>(); + private static final Map EQUIPMENT_TINT_CACHE = new HashMap<>(); + private static final Set SHEEP_WOOL_BONES = Set.of("bodywool", "headwool", "leg1wool", "leg2wool", "leg3wool", "leg4wool"); + private static final int PLAYER_OUTER_SKIN_CUBE_INDEX = 1; + private static final Category NON_KEY_CATEGORY = Category.create(Identifier.of("needsofnature", "main")); + private static final KeyBinding START_MANUAL_PEAK_KEY = KeyBindingHelper.registerKeyBinding( + new KeyBinding("key.needsofnature.start_manual_peak", Type.KEYSYM, 77, NON_KEY_CATEGORY) + ); + + public static void sendClientSetupWarning(String message) { + NonClientDiagnostics.sendSetupWarning(message, true); + } + + public void onInitializeClient() { + ClientLifecycleEvents.CLIENT_STARTED.register((ClientStarted)client -> NonStats.registerKnownEntityStats()); + NonDestroyedSkinClient.ensureConfigDirectory(); + AnimationEngineClient.initialize(); + EntityRendererFactories.register(NeedsOfNature.HORSE_LIQUID_COLLECTOR_ENTITY_TYPE, HorseLiquidCollectorRenderer::new); + EntityRendererFactories.register(NeedsOfNature.PREGNANCY_EGG_ENTITY_TYPE, PregnancyEggRenderer::new); + NonHudOverlay.registerReloadListener(); + NonClientResourceCaches.registerReloadListener(); + ClientTickEvents.END_CLIENT_TICK.register(NonHudOverlay::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NonSpinModelLock::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NonDebugSpinMusicClient::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NonPackStartupWarnings::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NonDestroyedSkinClient::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NonArmorShedClient::clientTick); + NonAccessoryTooltips.register(); + NonInventoryScreenHooks.register(); + NonCreditsButtonHooks.register(); + NonPlayerInteractionClient.register(); + NonWildfireGenderScreenHooks.register(); + NonArmorShedClient.register(); + ClientTickEvents.END_CLIENT_TICK.register((EndTick)client -> CLIENT_FALLBACK_TICKS++); + ClientTickEvents.END_CLIENT_TICK.register(NeedsOfNatureClient::syncLocalGenderMirror); + ClientTickEvents.END_CLIENT_TICK.register(NonWildfireGenderSync::clientTick); + ClientTickEvents.END_CLIENT_TICK.register(NeedsOfNatureClient::tickIntifaceBaseline); + ClientPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, client) -> { + NonIntifaceBridge.onWorldDisconnect(NeedsOfNature.getConfig()); + NeedsOfNature.clearRemoteHostRules(); + NonHudOverlay.onDisconnect(client); + NonPregnancyClientState.clear(); + NonDestroyedSkinClient.clear(); + NonArmorShedClient.clear(); + NonAccessoryShedSystem.clearAll(); + NonPeakStages.clearSyncedPeakStages(); + NonDebugSpinMusicClient.stopAll(client); + REACTIVE_IMPACT_PULSE_START_NANOS = Long.MIN_VALUE; + LAST_LOCAL_GENDER_SYNC_MASK = 0; + NonWildfireGenderSync.onWorldDisconnect(); + NonPlayerInteractionClient.clear(); + NonServerSettingsClient.clear(); + }); + ClientPlayConnectionEvents.JOIN.register((Join)(handler, sender, client) -> client.execute(() -> { + NonWildfireGenderSync.onWorldJoin(); + pushConfiguredPlayerGenderToServer(); + NonDestroyedSkinClient.scheduleLocalSkinUploadRetry(); + NonIntifaceBridge.onWorldJoin(NeedsOfNature.getConfig()); + })); + ClientTickEvents.END_CLIENT_TICK.register(NeedsOfNatureClient::handleVoluntaryStopInput); + ClientTickEvents.END_CLIENT_TICK.register(NeedsOfNatureClient::handleAttackInput); + ClientTickEvents.END_CLIENT_TICK.register(NeedsOfNatureClient::handleManualPeakInput); + ClientPlayNetworking.registerGlobalReceiver( + AttackStateS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> NonHudOverlay.setEscapeControlState( + payload.instanceId(), payload.attack(), payload.escapable(), payload.escapeProfile(), payload.pvpRole(), payload.progress() + ) + ) + ); + ClientPlayNetworking.registerGlobalReceiver(LiquidStateS2CPayload.ID, (payload, context) -> context.client().execute(() -> { + NonHudOverlay.setLiquidState(payload.stored(), payload.capacity(), payload.tintRgb()); + MinecraftClient client = context.client(); + if (client.player instanceof LiquidHolder holder) { + holder.setLiquidStored(payload.stored()); + } + })); + ClientPlayNetworking.registerGlobalReceiver(PregnancyStateS2CPayload.ID, (payload, context) -> context.client().execute(() -> { + String raw = payload.pregnantEntityTypeId(); + Identifier id = raw != null && !raw.isBlank() ? Identifier.tryParse(raw) : null; + NonPregnancyClientState.setPregnantEntityTypeId(id); + })); + ClientPlayNetworking.registerGlobalReceiver( + PregnancyCycleStateS2CPayload.ID, + (payload, context) -> context.client().execute(() -> NonPregnancyClientState.setCycleState(payload.enabled(), payload.day(), payload.phase())) + ); + ClientPlayNetworking.registerGlobalReceiver( + PlayerRuntimeSettingsS2CPayload.ID, + (payload, context) -> context.client().execute(() -> NonHudOverlay.setRuntimeAttackEscapeHits(payload.attackEscapeHits())) + ); + ClientPlayNetworking.registerGlobalReceiver( + GenderSelectionPromptS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> MinecraftClient.getInstance().setScreen(new NonGenderSelectionScreen(payload.allowedMask(), payload.currentMask(), payload.permanent())) + ) + ); + ClientPlayNetworking.registerGlobalReceiver(HostConfigSyncS2CPayload.ID, (payload, context) -> context.client().execute(() -> { + String raw = payload.json(); + if (raw != null && !raw.isBlank()) { + try { + HostConfigSyncData data = (HostConfigSyncData)HOST_CONFIG_SYNC_GSON.fromJson(raw, HostConfigSyncData.class); + if (data == null) { + return; + } + + MinecraftClient client = context.client(); + if (client.getServer() == null) { + NeedsOfNature.applyRemoteHostRules(data); + } + + NonServerSettingsClient.applyConfigSnapshot(payload.editableJson(), payload.revision(), payload.canEdit()); + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to parse host config sync payload.", e); + } + } + })); + ClientPlayNetworking.registerGlobalReceiver( + AnimationCatalogChunkS2CPayload.ID, + (payload, context) -> context.client() + .execute(() -> NonServerSettingsClient.acceptCatalogChunk(payload.revision(), payload.index(), payload.count(), payload.data())) + ); + ClientPlayNetworking.registerGlobalReceiver( + ServerConfigResultS2CPayload.ID, (payload, context) -> context.client().execute(() -> NonServerSettingsClient.handleConfigResult(payload)) + ); + ClientPlayNetworking.registerGlobalReceiver( + DestroyedSkinSyncS2CPayload.ID, + (payload, context) -> context.client().execute(() -> NonDestroyedSkinClient.setSkin(payload.playerUuid(), payload.stage(), payload.pngBytes())) + ); + ClientPlayNetworking.registerGlobalReceiver( + DestroyedSkinMaskSyncS2CPayload.ID, + (payload, context) -> context.client().execute(() -> NonDestroyedSkinClient.setMask(payload.playerUuid(), payload.stage(), payload.pngBytes())) + ); + ClientPlayNetworking.registerGlobalReceiver( + DestroyedSkinStateS2CPayload.ID, + (payload, context) -> context.client().execute(() -> NonDestroyedSkinClient.setStage(payload.playerUuid(), payload.stage())) + ); + ClientPlayNetworking.registerGlobalReceiver( + DestroyedSkinParticlesS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> NonDestroyedSkinClient.spawnRippedFabricParticles( + payload.playerUuid(), payload.x(), payload.y(), payload.z(), payload.count(), payload.seed() + ) + ) + ); + ClientPlayNetworking.registerGlobalReceiver( + MessStateS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> NonDestroyedSkinClient.setMess( + payload.playerUuid(), payload.vMess(), payload.aMess(), payload.mMess(), payload.tintRgb(), payload.liquidStored(), payload.liquidCapacity() + ) + ) + ); + ClientPlayNetworking.registerGlobalReceiver( + AccessoryShedStateS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> NonAccessoryShedSystem.setClientShedState(payload.playerUuid(), payload.shedV(), payload.shedA(), payload.forcedV(), payload.forcedA()) + ) + ); + ClientPlayNetworking.registerGlobalReceiver( + SkinRippedInfoRequestS2CPayload.ID, (payload, context) -> context.client().execute(NonDestroyedSkinClient::sendDetectedRippedSkinInfo) + ); + registerSoundCueResolver(); + registerIntifaceCueAnticipation(); + registerParticleCueResolver(); + ParticleFactoryRegistry.getInstance().register(NonParticles.LIQUID_PARTICLE, LiquidParticle.Factory::new); + ParticleFactoryRegistry.getInstance().register(NonParticles.LIQUID_PARTICLE_FALLING, LiquidFallingParticle.Factory::new); + ParticleFactoryRegistry.getInstance().register(NonParticles.LIQUID_PARTICLE_PUDDLE, LiquidPuddleParticle.Factory::new); + ParticleFactoryRegistry.getInstance().register(NonParticles.LIQUID_PARTICLE_WATER, LiquidWaterParticle.Factory::new); + ParticleFactoryRegistry.getInstance().register(NonParticles.SMALLHEART, SmallHeartParticle.Factory::new); + ParticleFactoryRegistry.getInstance().register(NonParticles.RIPPED_FABRIC, RippedFabricParticle.Factory::new); + NonAnimationRenderEvents.RESOLVE_RENDER + .register( + (NonAnimationRenderEvents.RenderResolver)(entity, entityTypeId, currentModel, currentTexture) -> { + if (NonDebugSpinMode.isEnabled()) { + return new NonAnimationRenderEvents.RenderOverride(NonDebugSpinMode.DEBUG_SPIN_MODEL_RESOURCE, NonDebugSpinMode.DEBUG_SPIN_TEXTURE_RESOURCE); + } + + NonAnimationRenderEvents.RenderOverride collectorOverride = resolveCollectorRenderOverride(entity); + if (collectorOverride != null) { + return collectorOverride; + } + + Identifier destroyedBaseTexture = NonDestroyedSkinClient.resolveBaseTexture(entity, resolveDestroyedSkinBaseTexture(entity, currentTexture)); + Identifier destroyedOverlayTexture = destroyedBaseTexture == null ? NonDestroyedSkinClient.resolveOverlayTexture(entity) : null; + List layers = new ArrayList<>(); + if (destroyedOverlayTexture != null) { + layers.add(destroyedOverlayTexture); + } + + layers.addAll(resolveSheepOverlays(entity)); + layers.addAll(resolveHorseOverlays(entity)); + layers.addAll(resolveWolfOverlays(entity)); + List layerTextures = layers.isEmpty() ? List.of() : List.copyOf(layers); + ItemStack maleRoleAccessory = NonTrinketsIntegration.getMaleRoleAccessoryStack(entity); + boolean forceMaleModel = !maleRoleAccessory.isEmpty(); + Map boneTextures = mergeIdentifierMaps(resolveSheepBoneTextures(entity), resolveAccessoryBoneTextures(maleRoleAccessory)); + Map boneVisibility = mergeBooleanMaps(resolveDonkeyChestBoneVisibility(entity), resolveSheepBoneVisibility(entity)); + Map> hiddenBoneCubeIndices = resolvePlayerSkinPartHiddenCubeIndices(entity); + boolean hasLayerTextures = !layerTextures.isEmpty(); + boolean hasBoneTextures = !boneTextures.isEmpty(); + boolean hasBoneVisibility = !boneVisibility.isEmpty(); + boolean hasHiddenBoneCubeIndices = !hiddenBoneCubeIndices.isEmpty(); + Identifier baseModel = currentModel; + if (baseModel == null && entityTypeId != null) { + baseModel = AnimationEngine.packId("entity/" + entityTypeId.getPath()); + } + + if (entity instanceof GenderHolder holder) { + GenderedModelResolver.ModelGender modelGender = forceMaleModel + ? GenderedModelResolver.ModelGender.MALE + : GenderedModelResolver.fromMask(holder.getGenderMask()); + if (baseModel == null) { + return destroyedBaseTexture == null && !hasLayerTextures && !hasBoneTextures && !hasBoneVisibility && !hasHiddenBoneCubeIndices + ? null + : createAnimationRenderOverride( + null, + destroyedBaseTexture, + hasLayerTextures ? layerTextures : null, + hasBoneTextures ? boneTextures : null, + null, + hasBoneVisibility ? boneVisibility : null, + hasHiddenBoneCubeIndices ? hiddenBoneCubeIndices : null + ); + } + + if (!forceMaleModel && GenderedModelResolver.hasGenderSuffix(baseModel)) { + return destroyedBaseTexture == null && !hasLayerTextures && !hasBoneTextures && !hasBoneVisibility && !hasHiddenBoneCubeIndices + ? null + : createAnimationRenderOverride( + null, + destroyedBaseTexture, + hasLayerTextures ? layerTextures : null, + hasBoneTextures ? boneTextures : null, + null, + hasBoneVisibility ? boneVisibility : null, + hasHiddenBoneCubeIndices ? hiddenBoneCubeIndices : null + ); + } + + Identifier model; + if (forceMaleModel) { + List modelRoots = ClientAnimationRuntime.findLatestModelRootsForActor(entity.getUuid()); + model = GeckoResourceResolver.resolveGenderedModelAcrossRoots(baseModel, modelRoots, GenderedModelResolver.ModelGender.MALE); + if (model == null) { + model = resolveExistingGenderedModel(GenderedModelResolver.stripGenderSuffix(baseModel), GenderedModelResolver.ModelGender.MALE); + } + } else { + model = resolveExistingGenderedModel(baseModel, modelGender); + } + + if (model != null && model.equals(baseModel)) { + model = null; + } + + return createAnimationRenderOverride( + model, + destroyedBaseTexture, + hasLayerTextures ? layerTextures : null, + hasBoneTextures ? boneTextures : null, + null, + hasBoneVisibility ? boneVisibility : null, + hasHiddenBoneCubeIndices ? hiddenBoneCubeIndices : null + ); + } else { + Identifier defaultGenderModel = resolveExistingGenderedModel(baseModel, GenderedModelResolver.ModelGender.MALE); + if (defaultGenderModel != null && defaultGenderModel.equals(baseModel)) { + defaultGenderModel = null; + } + + return defaultGenderModel == null + && destroyedBaseTexture == null + && !hasLayerTextures + && !hasBoneTextures + && !hasBoneVisibility + && !hasHiddenBoneCubeIndices + ? null + : createAnimationRenderOverride( + defaultGenderModel, + destroyedBaseTexture, + hasLayerTextures ? layerTextures : null, + hasBoneTextures ? boneTextures : null, + null, + hasBoneVisibility ? boneVisibility : null, + hasHiddenBoneCubeIndices ? hiddenBoneCubeIndices : null + ); + } + } + ); + } + + @Nullable + private static Identifier resolveExistingGenderedModel(@Nullable Identifier baseModel, GenderedModelResolver.ModelGender gender) { + return GenderedModelResolver.resolveExisting(baseModel, gender, GeckoResourceResolver::hasGeoModel); + } + + @Nullable + private static NonAnimationRenderEvents.RenderOverride resolveCollectorRenderOverride(LivingEntity entity) { + if (entity instanceof HorseLiquidCollectorEntity collector) { + Identifier texture = collector.isCollectorFull() ? HorseLiquidCollectorGeoModel.FULL_TEXTURE_ID : HorseLiquidCollectorGeoModel.EMPTY_TEXTURE_ID; + return new NonAnimationRenderEvents.RenderOverride(HorseLiquidCollectorGeoModel.MODEL_ID, texture); + } else { + return null; + } + } + + private static NonAnimationRenderEvents.RenderOverride createAnimationRenderOverride( + @Nullable Identifier model, + @Nullable Identifier texture, + @Nullable List layerTextures, + @Nullable Map boneTextures, + @Nullable Map boneItems, + @Nullable Map boneVisibility, + @Nullable Map> hiddenBoneCubeIndices + ) { + return new NonAnimationRenderEvents.RenderOverride(model, texture, layerTextures, boneTextures, boneItems, boneVisibility, hiddenBoneCubeIndices); + } + + @Nullable + private static Identifier resolveDestroyedSkinBaseTexture(LivingEntity entity, @Nullable Identifier currentTexture) { + if (entity instanceof AbstractClientPlayerEntity player) { + try { + SkinTextures skin = player.getSkin(); + if (skin != null && skin.body() != null && skin.body().texturePath() != null) { + return skin.body().texturePath(); + } + } catch (RuntimeException var4) { + } + } + + return currentTexture; + } + + private static Map resolveDonkeyChestBoneVisibility(LivingEntity entity) { + if (entity instanceof AbstractDonkeyEntity donkey) { + return donkey.hasChest() ? Map.of() : Map.of("left_chest", false, "right_chest", false); + } else { + return Map.of(); + } + } + + private static Map> resolvePlayerSkinPartHiddenCubeIndices(LivingEntity entity) { + if (entity instanceof AbstractClientPlayerEntity player) { + HashMap hiddenCubeIndices = new HashMap(); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.HAT, "head"); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.JACKET, "body"); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.RIGHT_SLEEVE, "rightarm"); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.LEFT_SLEEVE, "leftarm"); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.RIGHT_PANTS_LEG, "rightleg"); + hideOuterCubeIfDisabled(player, hiddenCubeIndices, PlayerModelPart.LEFT_PANTS_LEG, "leftleg"); + return hiddenCubeIndices.isEmpty() ? Map.of() : Map.copyOf(hiddenCubeIndices); + } else { + return Map.of(); + } + } + + private static void hideOuterCubeIfDisabled( + AbstractClientPlayerEntity player, Map> hiddenCubeIndices, PlayerModelPart part, String boneName + ) { + if (player != null && hiddenCubeIndices != null && part != null && boneName != null && !boneName.isBlank()) { + if (!player.isModelPartVisible(part)) { + hiddenCubeIndices.put(boneName, Set.of(1)); + } + } + } + + private static Map resolveSheepBoneTextures(LivingEntity entity) { + if (!(entity instanceof SheepEntity sheep)) { + return Map.of(); + } else { + if (sheep.isSheared()) { + return Map.of(); + } + + int rgb = getSheepColorRgb(sheep); + Identifier woolTexture = getTintedTexture(SHEEP_WOOL_TEXTURE, rgb, "sheep_wool", SHEEP_WOOL_TINT_CACHE); + Map textures = new HashMap<>(); + + for (String bone : SHEEP_WOOL_BONES) { + textures.put(bone, woolTexture); + } + + return Map.copyOf(textures); + } + } + + private static Map resolveAccessoryBoneTextures(ItemStack stack) { + if (stack != null && !stack.isEmpty()) { + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(Registries.ITEM.getId(stack.getItem())); + if (definition != null && !definition.actorVisualBehavior().animationBoneTextures().isEmpty()) { + Map textures = new HashMap<>(); + + for (Entry entry : definition.actorVisualBehavior().animationBoneTextures().entrySet()) { + NonTrinketsIntegration.ResolvedVisualLayer layer = NonTrinketsIntegration.resolveVisualLayer(stack, entry.getValue()); + if (layer != null) { + Identifier texture = layer.tintRgb() == 16777215 + ? layer.texture() + : getTintedEquipmentTexture(layer.texture(), layer.tintRgb(), "accessory_bone"); + textures.put(entry.getKey(), texture); + } + } + + return textures.isEmpty() ? Map.of() : Map.copyOf(textures); + } else { + return Map.of(); + } + } else { + return Map.of(); + } + } + + @SafeVarargs + private static Map mergeIdentifierMaps(Map... maps) { + if (maps != null && maps.length != 0) { + Map merged = new HashMap<>(); + + for (Map map : maps) { + if (map != null && !map.isEmpty()) { + merged.putAll(map); + } + } + + return merged.isEmpty() ? Map.of() : Map.copyOf(merged); + } else { + return Map.of(); + } + } + + private static List resolveSheepOverlays(LivingEntity entity) { + if (entity instanceof SheepEntity sheep) { + Identifier undercoatTexture = getTintedTexture( + SHEEP_WOOL_UNDERCOAT_TEXTURE, getSheepColorRgb(sheep), "sheep_wool_undercoat", SHEEP_WOOL_UNDERCOAT_TINT_CACHE + ); + return List.of(undercoatTexture); + } else { + return List.of(); + } + } + + private static Map resolveSheepBoneVisibility(LivingEntity entity) { + if (!(entity instanceof SheepEntity sheep)) { + return Map.of(); + } else { + if (!sheep.isSheared()) { + return Map.of(); + } + + Map visibility = new HashMap<>(); + + for (String bone : SHEEP_WOOL_BONES) { + visibility.put(bone, Boolean.FALSE); + } + + return Map.copyOf(visibility); + } + } + + private static int getSheepColorRgb(SheepEntity sheep) { + if (sheep == null) { + return DyeColor.WHITE.getEntityColor(); + } + + DyeColor color = sheep.getColor(); + return color == null ? DyeColor.WHITE.getEntityColor() : color.getEntityColor(); + } + + @SafeVarargs + private static Map mergeBooleanMaps(Map... maps) { + if (maps != null && maps.length != 0) { + Map merged = new HashMap<>(); + + for (Map map : maps) { + if (map != null && !map.isEmpty()) { + merged.putAll(map); + } + } + + return merged.isEmpty() ? Map.of() : Map.copyOf(merged); + } else { + return Map.of(); + } + } + + private static ItemStack createFilledBottlePropStack(LivingEntity entity) { + int tint = NonHudOverlay.getLiquidTintRgb() & 16777215; + if (tint == 0) { + tint = 15920063; + } + + PotionContentsComponent contents = new PotionContentsComponent( + Optional.of(Registries.POTION.getEntry(NonPotions.LIQUID)), Optional.of(tint), List.of(), Optional.empty() + ); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, contents); + if (entity instanceof LiquidHolder holder && holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY) { + NonPotions.setLiquidBottleEntityTypeId(stack, holder.getLiquidEntityTypeId()); + } else { + NonPotions.setLiquidBottleEntityTypeId(stack, null); + } + + return stack; + } + + private static boolean isFillBottleAnimation(Identifier animationId) { + if (animationId == null) { + return false; + } else if (!FILL_BOTTLE_ANIMATION_ID.getNamespace().equals(animationId.getNamespace())) { + return false; + } else { + String path = animationId.getPath(); + if (path != null && !path.isBlank()) { + String normalized = path.toLowerCase(Locale.ROOT).replaceFirst("\\.p\\d+$", ""); + return FILL_BOTTLE_ANIMATION_ID.getPath().equals(normalized); + } else { + return false; + } + } + } + + private static List resolveHorseOverlays(LivingEntity entity) { + if (entity instanceof AbstractHorseEntity horse) { + if (horse.isBaby()) { + return List.of(); + } + + List overlays = new ArrayList<>(); + if (horse instanceof HorseEntity vanillaHorse) { + HorseMarking marking = vanillaHorse.getMarking(); + Identifier markingTexture = mapHorseMarkingTexture(marking); + if (markingTexture != null) { + overlays.add(markingTexture); + } + + addSaddleOverlay(overlays, vanillaHorse); + ItemStack armor = vanillaHorse.getEquippedStack(EquipmentSlot.BODY); + if (!armor.isEmpty()) { + overlays.addAll(resolveHorseArmorTextures(armor)); + } + + return overlays.isEmpty() ? List.of() : List.copyOf(overlays); + } else { + addSaddleOverlay(overlays, horse); + return overlays.isEmpty() ? List.of() : List.copyOf(overlays); + } + } else { + return List.of(); + } + } + + private static void addSaddleOverlay(List overlays, AbstractHorseEntity horse) { + if (overlays != null && horse != null) { + ItemStack saddle = horse.getEquippedStack(EquipmentSlot.SADDLE); + if (!saddle.isEmpty()) { + overlays.addAll(resolveEquipmentLayerTextures(EquipmentAssetKeys.SADDLE, saddle, resolveHorseSaddleLayerType(horse), "horse_saddle")); + } + } + } + + private static LayerType resolveHorseSaddleLayerType(AbstractHorseEntity horse) { + EntityType type = horse == null ? null : horse.getType(); + if (type == EntityType.DONKEY) { + return LayerType.DONKEY_SADDLE; + } else if (type == EntityType.MULE) { + return LayerType.MULE_SADDLE; + } else if (type == EntityType.SKELETON_HORSE) { + return LayerType.SKELETON_HORSE_SADDLE; + } else { + return type == EntityType.ZOMBIE_HORSE ? LayerType.ZOMBIE_HORSE_SADDLE : LayerType.HORSE_SADDLE; + } + } + + private static Identifier mapHorseMarkingTexture(HorseMarking marking) { + if (marking == null || marking == HorseMarking.NONE) { + return null; + } else if (marking == HorseMarking.WHITE) { + return Identifier.ofVanilla("textures/entity/horse/horse_markings_white.png"); + } else if (marking == HorseMarking.WHITE_FIELD) { + return Identifier.ofVanilla("textures/entity/horse/horse_markings_whitefield.png"); + } else if (marking == HorseMarking.WHITE_DOTS) { + return Identifier.ofVanilla("textures/entity/horse/horse_markings_whitedots.png"); + } else { + return marking == HorseMarking.BLACK_DOTS ? Identifier.ofVanilla("textures/entity/horse/horse_markings_blackdots.png") : null; + } + } + + private static List resolveHorseArmorTextures(ItemStack armor) { + return resolveEquipmentLayerTextures(armor, LayerType.HORSE_BODY, "horse_armor"); + } + + private static List resolveEquipmentLayerTextures(ItemStack stack, LayerType layerType, String tintCachePrefix) { + if (stack != null && !stack.isEmpty() && layerType != null) { + EquippableComponent equippable = (EquippableComponent)stack.get(DataComponentTypes.EQUIPPABLE); + if (equippable == null) { + return List.of(); + } + + Optional> assetId = equippable.assetId(); + return assetId.isEmpty() ? List.of() : resolveEquipmentLayerTextures(assetId.get(), stack, layerType, tintCachePrefix); + } else { + return List.of(); + } + } + + private static List resolveEquipmentLayerTextures( + RegistryKey assetId, ItemStack stack, LayerType layerType, String tintCachePrefix + ) { + if (assetId != null && layerType != null) { + EquipmentModelLoader loader = resolveEquipmentModelLoader(); + if (loader == null) { + return List.of(); + } + + EquipmentModel model = loader.get(assetId); + if (model == null) { + return List.of(); + } + + List layers = model.getLayers(layerType); + if (layers != null && !layers.isEmpty()) { + DyedColorComponent dyed = (DyedColorComponent)stack.get(DataComponentTypes.DYED_COLOR); + List textures = new ArrayList<>(layers.size()); + + for (Layer layer : layers) { + if (layer != null) { + Identifier texture = layer.getFullTextureId(layerType); + if (texture != null) { + if (layer.dyeable().isPresent()) { + Optional fallbackColor = ((Dyeable)layer.dyeable().get()).colorWhenUndyed(); + Integer tint = dyed != null ? dyed.rgb() : fallbackColor.orElse(null); + if (tint != null) { + texture = getTintedEquipmentTexture(texture, tint, tintCachePrefix); + } + } + + textures.add(texture); + } + } + } + + return textures.isEmpty() ? List.of() : List.copyOf(textures); + } else { + return List.of(); + } + } else { + return List.of(); + } + } + + @Nullable + private static EquipmentModelLoader resolveEquipmentModelLoader() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return null; + } else { + return client.getEntityRenderDispatcher() instanceof EntityRenderManagerAccessor accessor ? accessor.afw$getEquipmentModelLoader() : null; + } + } + + private static List resolveWolfOverlays(LivingEntity entity) { + if (entity instanceof WolfEntity wolf) { + ArrayList overlays = new ArrayList(); + ItemStack armor = wolf.getEquippedStack(EquipmentSlot.BODY); + if (!armor.isEmpty()) { + overlays.addAll(resolveEquipmentLayerTextures(armor, LayerType.WOLF_BODY, "wolf_armor")); + } + + if (wolf.isTamed()) { + DyeColor collarColor = wolf.getCollarColor(); + if (collarColor != null) { + overlays.add(getTintedTexture(WOLF_COLLAR_TEXTURE, collarColor.getEntityColor(), "wolf_collar", WOLF_COLLAR_TINT_CACHE)); + } + } + + return overlays.isEmpty() ? List.of() : List.copyOf(overlays); + } else { + return List.of(); + } + } + + private static Identifier getTintedTexture(Identifier baseTexture, int rgb, String cachePrefix, Map cache) { + Identifier cached = cache.get(rgb); + if (cached != null) { + return cached; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return baseTexture; + } + + Optional resourceOpt = client.getResourceManager().getResource(baseTexture); + if (resourceOpt.isEmpty()) { + return baseTexture; + } + + Resource resource = resourceOpt.get(); + + try (InputStream in = resource.getInputStream()) { + NativeImage image = NativeImage.read(in); + tintImage(image, rgb); + String idPath = "textures/dynamic/" + cachePrefix + "_" + String.format("%06x", rgb) + ".png"; + Identifier id = Identifier.of("needsofnature", idPath); + client.getTextureManager().registerTexture(id, new NativeImageBackedTexture(id::toString, image)); + cache.put(rgb, id); + return id; + } catch (IOException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to tint texture {} (rgb={})", new Object[]{baseTexture, rgb, e}); + return baseTexture; + } + } + + private static Identifier getTintedEquipmentTexture(Identifier baseTexture, int rgb, String cachePrefix) { + if (baseTexture == null) { + return null; + } + + int tint = rgb & 16777215; + String cacheKey = baseTexture + "|" + tint; + Identifier cached = EQUIPMENT_TINT_CACHE.get(cacheKey); + if (cached != null) { + return cached; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return baseTexture; + } + + Optional resourceOpt = client.getResourceManager().getResource(baseTexture); + if (resourceOpt.isEmpty()) { + return baseTexture; + } + + Resource resource = resourceOpt.get(); + + try (InputStream in = resource.getInputStream()) { + NativeImage image = NativeImage.read(in); + tintImage(image, tint); + String textureKey = (baseTexture.getNamespace() + "_" + baseTexture.getPath()).replace('/', '_').replace('.', '_'); + String idPath = "textures/dynamic/" + cachePrefix + "_" + textureKey + "_" + String.format("%06x", tint) + ".png"; + Identifier id = Identifier.of("needsofnature", idPath); + client.getTextureManager().registerTexture(id, new NativeImageBackedTexture(id::toString, image)); + EQUIPMENT_TINT_CACHE.put(cacheKey, id); + return id; + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to tint equipment texture {} (rgb={})", new Object[]{baseTexture, tint, e}); + return baseTexture; + } + } + + private static void tintImage(NativeImage image, int rgb) { + int tintR = rgb >> 16 & 0xFF; + int tintG = rgb >> 8 & 0xFF; + int tintB = rgb & 0xFF; + int width = image.getWidth(); + int height = image.getHeight(); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int argb = image.getColorArgb(x, y); + int alpha = argb >>> 24 & 0xFF; + if (alpha != 0) { + int r = argb >>> 16 & 0xFF; + int g = argb >>> 8 & 0xFF; + int b = argb & 0xFF; + int outR = r * tintR / 255; + int outG = g * tintG / 255; + int outB = b * tintB / 255; + int outArgb = alpha << 24 | outR << 16 | outG << 8 | outB; + image.setColorArgb(x, y, outArgb); + } + } + } + } + + private static void handleVoluntaryStopInput(MinecraftClient client) { + if (client != null) { + ClientPlayerEntity player = client.player; + if (player != null) { + while (client.options.sneakKey.wasPressed()) { + if (ClientAnimationRuntime.isActorPendingOrActive(player.getUuid())) { + UUID instanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(player.getUuid()); + if (instanceId != null && ClientPlayNetworking.canSend(StopVoluntaryAnimationC2SPayload.ID)) { + ClientPlayNetworking.send(new StopVoluntaryAnimationC2SPayload(instanceId)); + } + } + } + } + } + } + + private static void handleAttackInput(MinecraftClient client) { + if (client != null && client.world != null) { + ClientPlayerEntity player = client.player; + if (player != null) { + UUID instanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(player.getUuid()); + if (NonHudOverlay.isAttackInstance(instanceId)) { + if (NonHudOverlay.consumeAttackInputClear()) { + drainPendingPresses(client.options.leftKey); + drainPendingPresses(client.options.rightKey); + } else { + int leftPresses = drainPendingPresses(client.options.leftKey); + int rightPresses = drainPendingPresses(client.options.rightKey); + if (leftPresses <= 0 || rightPresses <= 0) { + NonHudOverlay.AttackKey attackKey = NonHudOverlay.AttackKey.NONE; + if (leftPresses > 0) { + attackKey = NonHudOverlay.AttackKey.LEFT; + } else if (rightPresses > 0) { + attackKey = NonHudOverlay.AttackKey.RIGHT; + } + + if (attackKey != NonHudOverlay.AttackKey.NONE) { + NonHudOverlay.recordAttackInput(instanceId, attackKey); + if (NonHudOverlay.isServerControlledAttack() && ClientPlayNetworking.canSend(PlayerAttackInputC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerAttackInputC2SPayload(instanceId, attackKey.ordinal())); + } + } + + if (NonHudOverlay.isAttackProgressComplete() && ClientPlayNetworking.canSend(StopAttackAnimationC2SPayload.ID)) { + ClientPlayNetworking.send(new StopAttackAnimationC2SPayload(instanceId)); + NonHudOverlay.resetAttackProgress(); + } + } + } + } + } + } + } + + private static void handleManualPeakInput(MinecraftClient client) { + if (client != null && client.world != null) { + ClientPlayerEntity player = client.player; + if (player != null && !player.isSpectator()) { + while (START_MANUAL_PEAK_KEY.wasPressed()) { + UUID activeInstance = ClientAnimationRuntime.findLatestActiveInstanceContaining(player.getUuid()); + if (activeInstance == null && ClientPlayNetworking.canSend(StartManualPeakC2SPayload.ID)) { + ClientPlayNetworking.send(new StartManualPeakC2SPayload()); + } + } + } + } + } + + public static void pushConfiguredPlayerGenderToServer() { + pushConfiguredPlayerGenderToServer(NeedsOfNature.getConfig()); + } + + public static void pushConfiguredPlayerGenderToServer(@Nullable NonConfig config) { + if (config != null) { + int mask = config.getPlayerGenderMask() & 3; + if (mask == 0) { + mask = NonConfig.PlayerGenderSelection.FEMALE.mask(); + } + + if (ClientPlayNetworking.canSend(SetPlayerGenderC2SPayload.ID)) { + ClientPlayNetworking.send(new SetPlayerGenderC2SPayload(mask, false)); + } + + NonWildfireGenderSync.syncFromNonConfig(config); + NonWildfireGenderSync.syncDestroyedSkinOverrides(config); + } + } + + private static void syncLocalGenderMirror(MinecraftClient client) { + if (client != null && client.player != null) { + if (client.player instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask != 0 && mask != LAST_LOCAL_GENDER_SYNC_MASK) { + LAST_LOCAL_GENDER_SYNC_MASK = mask; + NonConfig config = NeedsOfNature.getConfig(); + NonWildfireGenderSync.syncFromMask(config, mask); + NonWildfireGenderSync.syncDestroyedSkinOverrides(config); + } + } + } + } + + private static void registerSoundCueResolver() { + NonAnimationSoundEvents.RESOLVE.register((NonAnimationSoundEvents.SoundResolver)context -> { + if (NonDebugSpinMode.isEnabled()) { + return NonAnimationSoundEvents.SoundOverride.skip(); + } + + if (isFillBottlePropCue(context)) { + UUID instanceId = context.instanceId(); + if (instanceId != null && context.anchor() != null) { + trySetAnimationRightHandPropOverride(instanceId, context.anchor().getUuid(), createFilledBottlePropStack(context.anchor())); + } + + return NonAnimationSoundEvents.SoundOverride.play(VANILLA_BOTTLE_FILL_SOUND, context.volume(), context.pitch()); + } else { + if (AnimationCueEffects.isReactiveImpact(context.effect())) { + boolean localPlayerCue = isLocalPlayerActorInInstance(context.instanceId()); + if (localPlayerCue && isPeakReactiveImpactCue(context)) { + triggerReactiveImpactFovPulse(); + } + + if (AnimationCueEffects.isSilentReactiveImpact(context.effect())) { + return NonAnimationSoundEvents.SoundOverride.skip(); + } + } + + Identifier soundId = NonSoundCues.resolveSoundId(context.effect(), context.anchor(), context.animationId(), context.stageIndex()); + if (soundId == null) { + return null; + } + + float volume = context.volume(); + if (NonSoundCues.isActionSoundId(soundId)) { + volume *= NeedsOfNature.getConfig().getActionSoundVolumeMultiplier(); + } + + return NonAnimationSoundEvents.SoundOverride.play(soundId, volume, context.pitch()); + } + }); + } + + private static void registerIntifaceCueAnticipation() { + AnimationSoundAnticipation.setListener(new AnimationSoundAnticipation.Listener() { + @Override + public int lookaheadMillis() { + NonConfig config = NeedsOfNature.getConfig(); + return config != null && config.isIntifaceEnabled() ? config.getIntifaceLatencyCompensationMs() : 0; + } + + @Override + public void anticipate(NonAnimationSoundEvents.SoundContext context) { + if (context != null && AnimationCueEffects.isReactiveImpact(context.effect())) { + if (NeedsOfNatureClient.isLocalPlayerActorInInstance(context.instanceId())) { + if (NeedsOfNatureClient.isPeakReactiveImpactCue(context)) { + NonIntifaceBridge.pulsePeakReactiveImpact(NeedsOfNature.getConfig(), NeedsOfNatureClient.nextSameEffectCueDelayMs(context)); + } else { + NonIntifaceBridge.pulseReactiveImpact(NeedsOfNature.getConfig(), NeedsOfNatureClient.nextSameEffectCueDelayMs(context)); + } + } + } + } + + @Override + public boolean allowNextStageLookahead(Identifier currentStageId, int stageIndex) { + return !NeedsOfNatureClient.isBirthLoopStage(currentStageId, stageIndex); + } + }); + } + + public static void clearGeneratedTintTextures() { + Set textureIds = new HashSet<>(); + textureIds.addAll(WOLF_COLLAR_TINT_CACHE.values()); + textureIds.addAll(SHEEP_WOOL_TINT_CACHE.values()); + textureIds.addAll(SHEEP_WOOL_UNDERCOAT_TINT_CACHE.values()); + textureIds.addAll(EQUIPMENT_TINT_CACHE.values()); + WOLF_COLLAR_TINT_CACHE.clear(); + SHEEP_WOOL_TINT_CACHE.clear(); + SHEEP_WOOL_UNDERCOAT_TINT_CACHE.clear(); + EQUIPMENT_TINT_CACHE.clear(); + INVALID_PARTICLE_COLOR_SOURCES_WARNED.clear(); + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + for (Identifier textureId : textureIds) { + client.getTextureManager().destroyTexture(textureId); + } + } + } + + private static void registerParticleCueResolver() { + NonAnimationParticleEvents.RESOLVE.register((NonAnimationParticleEvents.ParticleResolver)context -> { + if (context != null && NonParticles.isLiquidParticleId(context.particleId())) { + String colorSource = parseParticleColorSource(context.script()); + int tintRgb; + if (colorSource == null || "tank".equals(colorSource)) { + tintRgb = resolveParticleTankTint(context); + } else if ("actor".equals(colorSource)) { + Identifier actorTypeId = context.actor() == null ? null : Registries.ENTITY_TYPE.getId(context.actor().getType()); + tintRgb = NonLiquidSystem.resolveBottleTintRgb(actorTypeId); + } else { + warnInvalidParticleColorSource(context, colorSource); + tintRgb = resolveParticleTankTint(context); + } + + ParticleEffect particle = NonParticles.createLiquidEffect(context.particleId(), tintRgb); + return particle == null ? null : NonAnimationParticleEvents.ParticleOverride.spawn(particle); + } else { + return null; + } + }); + } + + private static int resolveParticleTankTint(NonAnimationParticleEvents.ParticleContext context) { + if (context.actor() instanceof PlayerEntity player) { + Integer syncedTint = NonDestroyedSkinClient.getLiquidTintRgb(player.getUuid()); + if (syncedTint != null) { + return syncedTint; + } + } + + MinecraftClient client = MinecraftClient.getInstance(); + return client == null || client.player == null || context.actor() != null && !context.actor().getUuid().equals(client.player.getUuid()) + ? NonLiquidSystem.resolveBottleTintRgb(null) + : NonHudOverlay.getLiquidTintRgb(); + } + + @Nullable + private static String parseParticleColorSource(@Nullable String script) { + if (script != null && !script.isBlank()) { + for (String entry : script.split("[;,\\r\\n]+")) { + String[] pair = entry.trim().split("=", 2); + if (pair.length == 2 && "color_source".equalsIgnoreCase(pair[0].trim())) { + return pair[1].trim().toLowerCase(Locale.ROOT); + } + } + + return null; + } else { + return null; + } + } + + private static void warnInvalidParticleColorSource(NonAnimationParticleEvents.ParticleContext context, String colorSource) { + String animation = context.animationId() == null ? "unknown" : context.animationId().toString(); + String warningKey = animation + "|" + colorSource; + if (INVALID_PARTICLE_COLOR_SOURCES_WARNED.add(warningKey)) { + NeedsOfNature.logSetupWarning("[NoN] Unknown particle color_source '{}' in animation {}; expected 'tank' or 'actor'.", colorSource, animation); + } + } + + private static boolean isPeakReactiveImpactCue(NonAnimationSoundEvents.SoundContext context) { + if (context == null) { + return false; + } else { + String effect = context.effect(); + if (!AnimationCueEffects.isReactiveImpact(effect)) { + return false; + } else if (NonSoundCues.isPeakReactiveImpactCue(effect, context.animationId(), context.stageIndex())) { + return true; + } else { + UUID instanceId = context.instanceId(); + if (instanceId == null) { + return false; + } else { + AnimationStageInfo stage = ClientAnimationRuntime.findStage(instanceId, context.stageIndex()); + if (stage != null && stage.animationId() != null) { + Identifier logicalStageId = stage.animationId(); + Integer stageNumber = NonPeakStages.stageNumberFromId(logicalStageId); + Integer peakStage = NonPeakStages.getPeakStage(NonPeakStages.baseAnimationId(logicalStageId)); + return stageNumber != null && peakStage != null && stageNumber.equals(peakStage); + } else { + return false; + } + } + } + } + } + + private static double nextSameEffectCueDelayMs(NonAnimationSoundEvents.SoundContext context) { + return context == null ? 0.0 : Math.max(0.0, context.nextSameEffectCueDelayMs()); + } + + private static boolean isLocalPlayerActorInInstance(@Nullable UUID instanceId) { + if (instanceId == null) { + return false; + } else { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + UUID playerInstance = ClientAnimationRuntime.findLatestActiveInstanceContaining(client.player.getUuid()); + return instanceId.equals(playerInstance); + } else { + return false; + } + } + } + + private static void tickIntifaceBaseline(MinecraftClient client) { + boolean active = false; + boolean peak = false; + double specialVibratorScalar = Double.NaN; + NonConfig config = NeedsOfNature.getConfig(); + int latencyCompensationMs = config == null ? 0 : config.getIntifaceLatencyCompensationMs(); + if (client != null && client.player != null) { + NeedsOfNatureClient.IntifaceAnimationState state = findLocalIntifaceAnimationState(client, latencyCompensationMs); + active = state != null; + if (state != null) { + peak = NonSoundCues.isPeakAnimationStage(state.stageAnimationId(), state.stageIndex()); + specialVibratorScalar = resolveSpecialIntifaceVibratorScalar(state); + } + } + + int energizedLevel = client != null && client.player != null ? NonHudOverlay.getEnergizedLevel(client.player) : 0; + double energizedPulse = energizedLevel > 0 ? NonHudOverlay.computeHeartPulse(energizedLevel, latencyCompensationMs) : 0.0; + NonIntifaceBridge.clientTick(config, active, peak, specialVibratorScalar, energizedLevel, energizedPulse); + } + + @Nullable + private static NeedsOfNatureClient.IntifaceAnimationState findLocalIntifaceAnimationState(MinecraftClient client, int latencyCompensationMs) { + if (client != null && client.player != null && client.world != null) { + UUID playerUuid = client.player.getUuid(); + UUID instanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(playerUuid); + if (instanceId == null) { + return null; + } + + ClientAnimationRuntime.StageTimelineSnapshot timeline = ClientAnimationRuntime.findStageTimeline(instanceId); + if (timeline != null && !timeline.stages().isEmpty()) { + int timelineStageIndex = Math.max(0, Math.min(timeline.stageIndex(), timeline.stages().size() - 1)); + AnimationStageInfo stage = timeline.stages().get(timelineStageIndex); + double projectedWorldTick = client.world.getTime() + Math.max(0, latencyCompensationMs) / 50.0; + double stageStartTick = timeline.stageStartTick(); + double stageEndTick = timeline.stageEndTick(); + double speed = Math.max(1.0E-4, timeline.speed()); + if (stageEndTick > stageStartTick + && projectedWorldTick >= stageEndTick + && !isBirthLoopStage(stage.animationId(), timelineStageIndex) + && timelineStageIndex + 1 < timeline.stages().size()) { + stage = timeline.stages().get(++timelineStageIndex); + stageStartTick = stageEndTick; + stageEndTick = -1.0; + speed = Math.max(1.0E-4, stage.speed()); + } + + Identifier stageAnimationId = stage.effectiveAnimationId(); + Identifier logicalStageAnimationId = stage.animationId(); + Identifier animationId = NonPeakStages.baseAnimationId(logicalStageAnimationId); + Integer stageNumber = NonPeakStages.stageNumberFromId(logicalStageAnimationId); + int logicalStageIndex = stageNumber == null ? timelineStageIndex : Math.max(0, stageNumber - 1); + double elapsedWorldTicks = Math.max(0.0, projectedWorldTick - stageStartTick); + double elapsedTicks = elapsedWorldTicks * speed; + double cycleTicks = AnimationGameplayMetadata.cycleTicks(logicalStageAnimationId); + if (cycleTicks <= 0.0) { + cycleTicks = stage.cycleTicks() > 0.0 ? stage.cycleTicks() : stage.lengthTicks(); + } + + double durationTicks = resolveIntifaceStageDurationTicks(animationId, logicalStageAnimationId, logicalStageIndex, cycleTicks); + double normalizedStageProgress = stageEndTick > stageStartTick + ? elapsedWorldTicks / (stageEndTick - stageStartTick) + : elapsedWorldTicks / Math.max(1.0, estimatedStageWorldDurationTicks(stage, durationTicks, speed)); + return new NeedsOfNatureClient.IntifaceAnimationState( + animationId, stageAnimationId, logicalStageIndex, elapsedTicks, normalizedStageProgress, cycleTicks + ); + } else { + return null; + } + } else { + return null; + } + } + + private static double estimatedStageWorldDurationTicks(AnimationStageInfo stage, double configuredDurationTicks, double speed) { + if (stage != null && !stage.loop()) { + double cycleTicks = stage.cycleTicks() > 0.0 ? stage.cycleTicks() : stage.lengthTicks(); + if (cycleTicks > 0.0) { + return cycleTicks / Math.max(1.0E-4, speed); + } + } + + return Math.max(1.0, configuredDurationTicks); + } + + private static double resolveIntifaceStageDurationTicks(Identifier animationId, Identifier stageAnimationId, int stageIndex, double cycleLengthTicks) { + double multiplier = AnimationGameplayMetadata.stageDurationMultiplier(stageAnimationId); + Integer override = AnimationGameplayMetadata.stageSeconds(stageAnimationId); + if (AnimationGameplayMetadata.isInfiniteStageSeconds(override)) { + override = null; + } + + if (override != null) { + return Math.max(1.0, Math.max(1, override) * 20.0 * multiplier); + } + + Identifier baseId = NonPeakStages.baseAnimationId(stageAnimationId); + Integer baseOverride = AnimationGameplayMetadata.stageSeconds(baseId); + if (AnimationGameplayMetadata.isInfiniteStageSeconds(baseOverride)) { + baseOverride = null; + } + + if (baseOverride != null) { + return Math.max(1.0, Math.max(1, baseOverride) * 20.0 * multiplier); + } + + Integer peakStage = NonPeakStages.getPeakStage(animationId); + int logicalStage = stageIndex + 1; + int seconds = Objects.equals(logicalStage, peakStage) + ? NeedsOfNature.getConfig().getPeakLoopProgressSeconds() + : NeedsOfNature.getConfig().getLoopProgressSeconds(); + return seconds > 0 ? Math.max(1.0, seconds * 20.0 * multiplier) : Math.max(1.0, cycleLengthTicks); + } + + private static double resolveSpecialIntifaceVibratorScalar(NeedsOfNatureClient.IntifaceAnimationState state) { + if (state == null || state.animationId() == null) { + return Double.NaN; + } else if (isManualPeakAnimation(state.animationId())) { + return resolveManualPeakVibratorScalar(state); + } else { + return isBirthAnimation(state.animationId(), state.stageAnimationId()) ? resolveBirthVibratorScalar(state) : Double.NaN; + } + } + + private static double resolveManualPeakVibratorScalar(NeedsOfNatureClient.IntifaceAnimationState state) { + Integer peakStage = NonPeakStages.getPeakStage(state.animationId()); + if (peakStage == null) { + return Double.NaN; + } else { + int logicalStage = state.stageIndex() + 1; + double progress = state.stageProgress(); + if (logicalStage < peakStage) { + return resolveManualPeakBuildupScalar(state, peakStage, logicalStage, progress); + } else { + return logicalStage == peakStage ? Math.max(0.0, Math.min(1.0, 1.0 - progress)) : Double.NaN; + } + } + } + + private static double resolveManualPeakBuildupScalar( + NeedsOfNatureClient.IntifaceAnimationState state, int peakStage, int logicalStage, double currentStageProgress + ) { + double completedDuration = 0.0; + double totalDuration = 0.0; + + for (int stageNumber = 1; stageNumber < peakStage; stageNumber++) { + Identifier stageId = AnimationIds.stageId(state.animationId(), stageNumber); + double cycleTicks = AnimationGameplayMetadata.cycleTicks(stageId); + if (cycleTicks <= 0.0 && stageNumber == logicalStage) { + cycleTicks = state.cycleDurationTicks(); + } + + double duration = resolveIntifaceStageDurationTicks(state.animationId(), stageId, stageNumber - 1, cycleTicks); + totalDuration += duration; + if (stageNumber < logicalStage) { + completedDuration += duration; + } else if (stageNumber == logicalStage) { + completedDuration += duration * currentStageProgress; + } + } + + return totalDuration <= 0.0 ? 0.0 : Math.max(0.0, Math.min(1.0, completedDuration / totalDuration)); + } + + private static double resolveBirthVibratorScalar(NeedsOfNatureClient.IntifaceAnimationState state) { + int logicalStage = state.stageIndex() + 1; + double progress = state.cycleProgress(); + + return switch (logicalStage) { + case 1 -> progress; + case 2 -> 0.6 + 0.4 * progress; + case 3 -> 1.0 - progress; + default -> Double.NaN; + }; + } + + private static boolean isManualPeakAnimation(Identifier animationId) { + return animationId != null && animationId.getPath().contains("manual_peak"); + } + + private static boolean isBirthAnimation(@Nullable Identifier animationId, @Nullable Identifier stageAnimationId) { + return hasBirthAnimationPath(animationId) || hasBirthAnimationPath(stageAnimationId); + } + + private static boolean isBirthLoopStage(@Nullable Identifier stageAnimationId, int stageIndex) { + Integer stageNumber = NonPeakStages.stageNumberFromId(stageAnimationId); + int logicalStage = stageNumber == null ? stageIndex + 1 : stageNumber; + return logicalStage == 2 && hasBirthAnimationPath(stageAnimationId); + } + + private static boolean hasBirthAnimationPath(@Nullable Identifier animationId) { + return animationId == null ? false : animationId.getPath().contains("birth"); + } + + private static boolean isFillBottlePropCue(NonAnimationSoundEvents.SoundContext context) { + if (context == null) { + return false; + } + + if (!isFillBottleAnimation(context.animationId())) { + return false; + } + + String effect = context.effect(); + return "fillbottle_prop_fill".equalsIgnoreCase(effect); + } + + private static boolean trySetAnimationRightHandPropOverride(UUID instanceId, UUID actorUuid, ItemStack stack) { + if (instanceId != null && actorUuid != null && stack != null && !stack.isEmpty()) { + ClientAnimationRuntime.setRightHandPropOverride(instanceId, actorUuid, stack); + return true; + } else { + return false; + } + } + + private static int drainPendingPresses(KeyBinding key) { + int count = 0; + + while (key.wasPressed()) { + count++; + } + + return count; + } + + private static void triggerReactiveImpactFovPulse() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + REACTIVE_IMPACT_PULSE_START_NANOS = System.nanoTime(); + } + } + + public static float getReactiveImpactFovOffset(float tickDelta) { + long pulseStartNanos = REACTIVE_IMPACT_PULSE_START_NANOS; + if (pulseStartNanos == Long.MIN_VALUE) { + return 0.0F; + } else { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return 0.0F; + } else { + long nowNanos = System.nanoTime(); + if (nowNanos < pulseStartNanos) { + REACTIVE_IMPACT_PULSE_START_NANOS = nowNanos; + return -0.5F; + } else { + float elapsed = (float)((nowNanos - pulseStartNanos) / 5.0E7); + if (elapsed <= 3.0F) { + return -0.5F; + } else { + float recovery = elapsed - 3.0F; + if (recovery >= 14.0F) { + REACTIVE_IMPACT_PULSE_START_NANOS = Long.MIN_VALUE; + return 0.0F; + } else { + float t = MathHelper.clamp(recovery / 14.0F, 0.0F, 1.0F); + float remaining = 1.0F - t; + float eased = remaining * remaining; + return -0.5F * eased; + } + } + } + } + } + } + + private static long getCurrentClientTicks(MinecraftClient client) { + return client != null && client.world != null ? client.world.getTime() : CLIENT_FALLBACK_TICKS; + } + + private record IntifaceAnimationState( + Identifier animationId, Identifier stageAnimationId, int stageIndex, double stageElapsedTicks, double normalizedStageProgress, double cycleDurationTicks + ) { + double stageProgress() { + return Math.max(0.0, Math.min(1.0, this.normalizedStageProgress)); + } + + double cycleProgress() { + return this.cycleDurationTicks <= 0.0 ? 0.0 : Math.max(0.0, Math.min(1.0, this.stageElapsedTicks / this.cycleDurationTicks)); + } + } +} diff --git a/src/main/java/com/nonid/NonAccessoryEffects.java b/src/main/java/com/nonid/NonAccessoryEffects.java new file mode 100644 index 0000000..55e7b80 --- /dev/null +++ b/src/main/java/com/nonid/NonAccessoryEffects.java @@ -0,0 +1,79 @@ +package com.nonid; + +public record NonAccessoryEffects( + double liquidDecayMultiplier, + boolean equalizeLiquidDecayContext, + double playerEnergyGainMultiplier, + int liquidCapacityAdd, + double liquidGainMultiplier, + double filledEffectMultiplier, + double pregnancyChanceMultiplier, + double pregnancyDurationMultiplier, + double messGainMultiplier, + double destroyedSkinDamageMultiplier, + int attackEscapeHitsAdd, + double attackEscapeDamageMultiplier, + double playerEnergyAuraMultiplier, + double nearAnimationMobEnergyMultiplier +) { + public static final NonAccessoryEffects NEUTRAL = new NonAccessoryEffects(1.0, false, 1.0, 0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0, 1.0, 1.0, 1.0); + + public NonAccessoryEffects combine(NonAccessoryEffects other) { + return other == null + ? this + : new NonAccessoryEffects( + this.liquidDecayMultiplier * other.liquidDecayMultiplier, + this.equalizeLiquidDecayContext || other.equalizeLiquidDecayContext, + this.playerEnergyGainMultiplier * other.playerEnergyGainMultiplier, + this.liquidCapacityAdd + other.liquidCapacityAdd, + this.liquidGainMultiplier * other.liquidGainMultiplier, + this.filledEffectMultiplier * other.filledEffectMultiplier, + this.pregnancyChanceMultiplier * other.pregnancyChanceMultiplier, + this.pregnancyDurationMultiplier * other.pregnancyDurationMultiplier, + this.messGainMultiplier * other.messGainMultiplier, + this.destroyedSkinDamageMultiplier * other.destroyedSkinDamageMultiplier, + this.attackEscapeHitsAdd + other.attackEscapeHitsAdd, + this.attackEscapeDamageMultiplier * other.attackEscapeDamageMultiplier, + this.playerEnergyAuraMultiplier * other.playerEnergyAuraMultiplier, + this.nearAnimationMobEnergyMultiplier * other.nearAnimationMobEnergyMultiplier + ); + } + + public NonAccessoryEffects globalOnly() { + return new NonAccessoryEffects( + 1.0, + false, + this.playerEnergyGainMultiplier, + 0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + this.destroyedSkinDamageMultiplier, + this.attackEscapeHitsAdd, + this.attackEscapeDamageMultiplier, + this.playerEnergyAuraMultiplier, + this.nearAnimationMobEnergyMultiplier + ); + } + + public NonAccessoryEffects tankScopedOnly() { + return new NonAccessoryEffects( + this.liquidDecayMultiplier, + this.equalizeLiquidDecayContext, + 1.0, + this.liquidCapacityAdd, + this.liquidGainMultiplier, + this.filledEffectMultiplier, + this.pregnancyChanceMultiplier, + this.pregnancyDurationMultiplier, + this.messGainMultiplier, + 1.0, + 0, + 1.0, + 1.0, + 1.0 + ); + } +} diff --git a/src/main/java/com/nonid/NonAccessoryShedSystem.java b/src/main/java/com/nonid/NonAccessoryShedSystem.java new file mode 100644 index 0000000..1b250d6 --- /dev/null +++ b/src/main/java/com/nonid/NonAccessoryShedSystem.java @@ -0,0 +1,197 @@ +package com.nonid; + +import com.nonid.data.NonAccessoryDefinitions; +import com.nonid.data.NonLiquidRoles; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.network.AccessoryShedStateS2CPayload; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; + +public final class NonAccessoryShedSystem { + private static final String V_SLOT = "legs/v"; + private static final String A_SLOT = "legs/a"; + private static final Map SHED_STATES = new ConcurrentHashMap<>(); + + private NonAccessoryShedSystem() { + } + + public static NonAccessoryShedSystem.ShedState resolveShedStateForAnimation(Identifier animationId) { + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(animationId); + if (roles != null && !roles.injectorRoles().isEmpty()) { + boolean shedV = false; + boolean shedA = false; + + for (NonLiquidRoles.InjectorRole role : roles.injectorRoles().values()) { + if (role == NonLiquidRoles.InjectorRole.V) { + shedV = true; + } else if (role == NonLiquidRoles.InjectorRole.A) { + shedA = true; + } + } + + return new NonAccessoryShedSystem.ShedState(shedV, shedA, false, false); + } else { + return NonAccessoryShedSystem.ShedState.NONE; + } + } + + public static NonAccessoryShedSystem.ShedState forSlot(String slotId) { + if (slotId == null) { + return NonAccessoryShedSystem.ShedState.NONE; + } else { + String slot = slotId.toLowerCase(Locale.ROOT); + if (slot.equals("legs/v") || slot.startsWith("legs/v/")) { + return new NonAccessoryShedSystem.ShedState(true, false, true, false); + } else { + return !slot.equals("legs/a") && !slot.startsWith("legs/a/") + ? NonAccessoryShedSystem.ShedState.NONE + : new NonAccessoryShedSystem.ShedState(false, true, false, true); + } + } + } + + public static void addServerShedState(ServerWorld world, ServerPlayerEntity player, NonAccessoryShedSystem.ShedState state) { + if (world != null && player != null && state != null && !state.isEmpty()) { + UUID uuid = player.getUuid(); + NonAccessoryShedSystem.ShedState merged = SHED_STATES.getOrDefault(uuid, NonAccessoryShedSystem.ShedState.NONE).merge(state); + SHED_STATES.put(uuid, merged); + syncToWorld(world, uuid, merged); + NonTrinketsIntegration.forceAccessorySync(player); + } + } + + public static void clearServerShedState(ServerWorld world, UUID playerUuid) { + if (world != null && playerUuid != null) { + NonAccessoryShedSystem.ShedState removed = SHED_STATES.remove(playerUuid); + if (removed != null && !removed.isEmpty()) { + syncToWorld(world, playerUuid, NonAccessoryShedSystem.ShedState.NONE); + if (world.getEntity(playerUuid) instanceof ServerPlayerEntity player) { + NonTrinketsIntegration.forceAccessorySync(player); + } + } + } + } + + public static void syncAllToViewer(ServerPlayerEntity viewer) { + if (viewer != null && !SHED_STATES.isEmpty()) { + for (Entry entry : SHED_STATES.entrySet()) { + NonAccessoryShedSystem.ShedState state = entry.getValue(); + if (state != null && !state.isEmpty()) { + ServerPlayNetworking.send( + viewer, new AccessoryShedStateS2CPayload(entry.getKey(), state.shedV(), state.shedA(), state.forcedV(), state.forcedA()) + ); + } + } + } + } + + public static void setClientShedState(UUID playerUuid, boolean shedV, boolean shedA, boolean forcedV, boolean forcedA) { + if (playerUuid != null) { + NonAccessoryShedSystem.ShedState state = new NonAccessoryShedSystem.ShedState(shedV, shedA, forcedV, forcedA); + if (state.isEmpty()) { + SHED_STATES.remove(playerUuid); + } else { + SHED_STATES.put(playerUuid, state); + } + } + } + + public static void clearAll() { + SHED_STATES.clear(); + } + + public static void tickServerWorld(ServerWorld world) { + if (world != null && !SHED_STATES.isEmpty()) { + for (UUID playerUuid : List.copyOf(SHED_STATES.keySet())) { + if (world.getEntity(playerUuid) instanceof ServerPlayerEntity player && !AnimationService.isActorPendingOrActive(world, playerUuid)) { + clearServerShedState(world, player.getUuid()); + } + } + } + } + + public static NonAccessoryShedSystem.ShedState getShedState(UUID playerUuid) { + return playerUuid == null ? NonAccessoryShedSystem.ShedState.NONE : SHED_STATES.getOrDefault(playerUuid, NonAccessoryShedSystem.ShedState.NONE); + } + + public static boolean isSlotShed(UUID playerUuid, String slotId) { + return getShedState(playerUuid).isSlotShed(slotId); + } + + public static boolean isSlotVisuallyShed(UUID playerUuid, String slotId, Identifier itemId) { + NonAccessoryShedSystem.ShedState state = getShedState(playerUuid); + if (!state.isSlotShed(slotId)) { + return false; + } else { + return state.isSlotForcedShed(slotId) ? true : !NonAccessoryDefinitions.getBehavior(itemId).ignoreInjectorSlotVisualShedding(); + } + } + + public static boolean isSlotEffectSuppressed(UUID playerUuid, String slotId, Identifier itemId) { + NonAccessoryShedSystem.ShedState state = getShedState(playerUuid); + if (!state.isSlotShed(slotId)) { + return false; + } else { + return state.isSlotForcedShed(slotId) ? true : !NonAccessoryDefinitions.getBehavior(itemId).ignoreInjectorSlotEffectSuppression(); + } + } + + private static void syncToWorld(ServerWorld world, UUID playerUuid, NonAccessoryShedSystem.ShedState state) { + AccessoryShedStateS2CPayload payload = new AccessoryShedStateS2CPayload(playerUuid, state.shedV(), state.shedA(), state.forcedV(), state.forcedA()); + + for (ServerPlayerEntity viewer : world.getPlayers()) { + ServerPlayNetworking.send(viewer, payload); + } + } + + public record ShedState(boolean shedV, boolean shedA, boolean forcedV, boolean forcedA) { + public static final NonAccessoryShedSystem.ShedState NONE = new NonAccessoryShedSystem.ShedState(false, false, false, false); + + public boolean isEmpty() { + return !this.shedV && !this.shedA; + } + + public NonAccessoryShedSystem.ShedState merge(NonAccessoryShedSystem.ShedState other) { + return other != null && !other.isEmpty() + ? new NonAccessoryShedSystem.ShedState( + this.shedV || other.shedV, this.shedA || other.shedA, this.forcedV || other.forcedV, this.forcedA || other.forcedA + ) + : this; + } + + public boolean isSlotShed(String slotId) { + if (slotId == null) { + return false; + } else { + String slot = slotId.toLowerCase(Locale.ROOT); + if (slot.equals("legs/v") || slot.startsWith("legs/v/")) { + return this.shedV; + } else { + return !slot.equals("legs/a") && !slot.startsWith("legs/a/") ? false : this.shedA; + } + } + } + + public boolean isSlotForcedShed(String slotId) { + if (slotId == null) { + return false; + } else { + String slot = slotId.toLowerCase(Locale.ROOT); + if (slot.equals("legs/v") || slot.startsWith("legs/v/")) { + return this.forcedV; + } else { + return !slot.equals("legs/a") && !slot.startsWith("legs/a/") ? false : this.forcedA; + } + } + } + } +} diff --git a/src/main/java/com/nonid/NonAdvancementHooks.java b/src/main/java/com/nonid/NonAdvancementHooks.java new file mode 100644 index 0000000..5d74987 --- /dev/null +++ b/src/main/java/com/nonid/NonAdvancementHooks.java @@ -0,0 +1,208 @@ +package com.nonid; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.advancement.AdvancementEntry; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; + +final class NonAdvancementHooks { + private static final int MOTHER_NATURE_PREGNANCIES = 25; + private static final int COLLECTORS_HABIT_BOTTLES = 25; + private static final int FIELD_RESEARCH_TYPES = 5; + private static final int TOSSED_AROUND_CHAIN = 5; + private static final int STABLE_DIET_CHAIN = 3; + private static final String ROOT = "root"; + private static final String NATURAL_CURIOSITY = "natural_curiosity"; + private static final String FILLED_TO_THE_BRIM = "filled_to_the_brim"; + private static final String FAVOURITISM = "favouritism"; + private static final String FIRST_PREGNANCY = "first_pregnancy"; + private static final String MOTHER_NATURE = "mother_nature"; + private static final String MESSED_UP = "messed_up"; + private static final String GROUP_ACTIVITY = "group_activity"; + private static final String TOSSED_AROUND = "tossed_around"; + private static final String COLLECTORS_HABIT = "collectors_habit"; + private static final String FIELD_RESEARCH = "field_research"; + private static final String BAD_IDEA_BEACON = "bad_idea_beacon"; + private static final String PRODUCTIVE_PREGNANCY = "productive_pregnancy"; + private static final String EMERGENCY_SNACK = "emergency_snack"; + private static final String STABLE_DIET = "stable_diet"; + private static final String UDDERLY_UNFORTUNATE = "udderly_unfortunate"; + private static final String ACQUIRE_HARDWARE = "acquire_hardware"; + private static final Map FIELD_RESEARCH_STATES = new ConcurrentHashMap<>(); + private static final Map TOSSED_AROUND_STREAKS = new ConcurrentHashMap<>(); + private static final Map STABLE_DIET_STREAKS = new ConcurrentHashMap<>(); + + private NonAdvancementHooks() { + } + + static void clearRuntimeState() { + FIELD_RESEARCH_STATES.clear(); + TOSSED_AROUND_STREAKS.clear(); + STABLE_DIET_STREAKS.clear(); + } + + static void clearPlayerState(UUID playerUuid) { + if (playerUuid != null) { + FIELD_RESEARCH_STATES.remove(playerUuid); + TOSSED_AROUND_STREAKS.remove(playerUuid); + STABLE_DIET_STREAKS.remove(playerUuid); + } + } + + static void grant(ServerPlayerEntity player, String path) { + if (player != null && path != null && !path.isBlank()) { + if (player.getEntityWorld() instanceof ServerWorld world) { + MinecraftServer server = world.getServer(); + if (server != null) { + if (!"root".equals(path)) { + grant(player, "root"); + } + + AdvancementEntry advancement = server.getAdvancementLoader().get(NeedsOfNature.id(path)); + if (advancement != null) { + player.getAdvancementTracker().grantCriterion(advancement, "done"); + } + } + } + } + } + + static void grantEmergencySnack(ServerPlayerEntity player) { + grant(player, "emergency_snack"); + recordStableDietSnack(player); + } + + static void resetStableDietStreak(ServerPlayerEntity player) { + if (player != null) { + STABLE_DIET_STREAKS.remove(player.getUuid()); + } + } + + private static void recordStableDietSnack(ServerPlayerEntity player) { + if (player != null) { + int streak = STABLE_DIET_STREAKS.merge(player.getUuid(), 1, Integer::sum); + if (streak >= 3) { + grant(player, "stable_diet"); + } + } + } + + static void checkLiquidAdvancements(ServerPlayerEntity player, LiquidHolder holder) { + if (player != null && holder != null) { + int capacity = holder.getLiquidCapacity(); + if (capacity > 0 && holder.getLiquidStored() >= capacity) { + grant(player, "filled_to_the_brim"); + if (holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY && holder.getLiquidEntityTypeId() != null) { + grant(player, "favouritism"); + } + } + } + } + + static void trackFieldResearch(ServerWorld world, ServerPlayerEntity player, Map perEntityAmounts) { + if (world != null && player != null && perEntityAmounts != null && !perEntityAmounts.isEmpty()) { + Set gainedTypes = new LinkedHashSet<>(); + + for (Entry entry : perEntityAmounts.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null && entry.getValue() > 0) { + gainedTypes.add(entry.getKey()); + } + } + + if (!gainedTypes.isEmpty()) { + long day = world.getTimeOfDay() / 24000L; + UUID playerUuid = player.getUuid(); + NonAdvancementHooks.FieldResearchState existing = FIELD_RESEARCH_STATES.get(playerUuid); + Set entityTypes = new LinkedHashSet<>(); + if (existing != null && existing.day() == day) { + entityTypes.addAll(existing.entityTypes()); + } + + entityTypes.addAll(gainedTypes); + FIELD_RESEARCH_STATES.put(playerUuid, new NonAdvancementHooks.FieldResearchState(day, Set.copyOf(entityTypes))); + if (entityTypes.size() >= 5) { + grant(player, "field_research"); + } + } + } + } + + static void checkBadIdeaBeacon(ServerPlayerEntity player) { + if (player != null && player instanceof EnergyHolder energyHolder) { + if (energyHolder.getEnergy() >= 200) { + if (NeedsOfNature.getDestroyedSkinStageForPlayer(player) >= 4) { + grant(player, "bad_idea_beacon"); + } + } + } + } + + static void grantNaturalCuriosity(ServerPlayerEntity player) { + grant(player, "natural_curiosity"); + } + + static void grantGroupActivity(ServerPlayerEntity player) { + grant(player, "group_activity"); + } + + static void grantMessedUp(ServerPlayerEntity player) { + grant(player, "messed_up"); + } + + static void checkCollectorsHabit(ServerPlayerEntity player) { + if (player != null) { + if (NonStats.getBottlesFilled(player) >= 25) { + grant(player, "collectors_habit"); + } + } + } + + static void resetTossedAround(ServerPlayerEntity player) { + if (player != null) { + TOSSED_AROUND_STREAKS.remove(player.getUuid()); + } + } + + static void recordTossedAround(ServerPlayerEntity player) { + if (player != null) { + int streak = TOSSED_AROUND_STREAKS.merge(player.getUuid(), 1, Integer::sum); + if (streak >= 5) { + grant(player, "tossed_around"); + } + } + } + + static void grantFirstPregnancy(ServerPlayerEntity player) { + grant(player, "first_pregnancy"); + } + + static void checkMotherNature(ServerPlayerEntity player) { + if (player != null) { + if (NonStats.getPregnanciesCompleted(player) >= 25) { + grant(player, "mother_nature"); + } + } + } + + static void grantProductivePregnancy(ServerPlayerEntity player) { + grant(player, "productive_pregnancy"); + } + + static void grantUdderlyUnfortunate(ServerPlayerEntity player) { + grant(player, "udderly_unfortunate"); + } + + static void grantAcquireHardware(ServerPlayerEntity player) { + grant(player, "acquire_hardware"); + } + + private record FieldResearchState(long day, Set entityTypes) { + } +} diff --git a/src/main/java/com/nonid/NonAnimationMatcher.java b/src/main/java/com/nonid/NonAnimationMatcher.java new file mode 100644 index 0000000..8a9d76a --- /dev/null +++ b/src/main/java/com/nonid/NonAnimationMatcher.java @@ -0,0 +1,181 @@ +package com.nonid; + +import com.nonid.internal.animation.api.AnimationService; +import java.util.List; +import java.util.Set; +import net.minecraft.entity.Entity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationMatcher { + private NonAnimationMatcher() { + } + + @Nullable + public static AnimationService.MatchedAnimation findAutomatic( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds + ) { + return findAutomatic(world, actors, requiredTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, null, null, false); + } + + @Nullable + public static AnimationService.MatchedAnimation findAutomatic( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable AnimationService.AnimationCandidateFilter additionalFilter, + @Nullable Identifier joinPredecessorAnimationId + ) { + return findAutomatic( + world, + actors, + requiredTags, + positionAnchor, + requireStartEligibility, + requiredActiveActor, + excludedAnimationIds, + additionalFilter, + joinPredecessorAnimationId, + false + ); + } + + @Nullable + public static AnimationService.MatchedAnimation findAutomaticAttack( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds + ) { + return findAutomatic(world, actors, requiredTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, null, null, true); + } + + @Nullable + public static AnimationService.MatchedAnimation findAutomaticAttack( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable AnimationService.AnimationCandidateFilter additionalFilter, + @Nullable Identifier joinPredecessorAnimationId + ) { + return findAutomatic( + world, + actors, + requiredTags, + positionAnchor, + requireStartEligibility, + requiredActiveActor, + excludedAnimationIds, + additionalFilter, + joinPredecessorAnimationId, + true + ); + } + + @Nullable + private static AnimationService.MatchedAnimation findAutomatic( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable AnimationService.AnimationCandidateFilter additionalFilter, + @Nullable Identifier joinPredecessorAnimationId, + boolean attackSelection + ) { + AnimationService.AnimationCandidateFilter filter = (candidateWorld, definition, candidateActors) -> (!attackSelection || definition.attackEligible()) + && NonInjectorMatchPolicy.allowsAutomaticNonMatch(candidateWorld, definition, candidateActors) + && (additionalFilter == null || additionalFilter.test(candidateWorld, definition, candidateActors)); + return AnimationService.findAnimationForActors( + world, + actors, + new AnimationService.MatchOptions( + requiredTags == null ? Set.of() : requiredTags, + positionAnchor, + requireStartEligibility, + requiredActiveActor, + excludedAnimationIds == null ? Set.of() : excludedAnimationIds, + filter, + joinPredecessorAnimationId + ) + ); + } + + public static List findEligibleAutomatic( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable Identifier joinPredecessorAnimationId + ) { + return findEligibleAutomatic( + world, actors, requiredTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, joinPredecessorAnimationId, false + ); + } + + public static List findEligibleAutomaticAttack( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable Identifier joinPredecessorAnimationId + ) { + return findEligibleAutomatic( + world, actors, requiredTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, joinPredecessorAnimationId, true + ); + } + + private static List findEligibleAutomatic( + ServerWorld world, + List actors, + @Nullable Set requiredTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable Identifier joinPredecessorAnimationId, + boolean attackSelection + ) { + AnimationService.AnimationCandidateFilter filter = (candidateWorld, definition, candidateActors) -> (!attackSelection || definition.attackEligible()) + && NonInjectorMatchPolicy.allowsAutomaticNonMatch(candidateWorld, definition, candidateActors); + return AnimationService.findEligibleAnimationsForActors( + world, + actors, + new AnimationService.MatchOptions( + requiredTags == null ? Set.of() : requiredTags, + positionAnchor, + requireStartEligibility, + requiredActiveActor, + excludedAnimationIds == null ? Set.of() : excludedAnimationIds, + filter, + joinPredecessorAnimationId + ) + ); + } +} diff --git a/src/main/java/com/nonid/NonAnimationRescueSystem.java b/src/main/java/com/nonid/NonAnimationRescueSystem.java new file mode 100644 index 0000000..2ae8022 --- /dev/null +++ b/src/main/java/com/nonid/NonAnimationRescueSystem.java @@ -0,0 +1,92 @@ +package com.nonid; + +import com.nonid.internal.animation.server.ServerAnimationController; +import java.util.Comparator; +import java.util.UUID; +import net.fabricmc.fabric.api.event.player.AttackEntityCallback; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.projectile.ProjectileEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationRescueSystem { + private NonAnimationRescueSystem() { + } + + static void register() { + AttackEntityCallback.EVENT + .register( + (AttackEntityCallback)(player, world, hand, entity, hitResult) -> { + if (hand == Hand.MAIN_HAND + && world instanceof ServerWorld serverWorld + && player instanceof ServerPlayerEntity attacker + && entity instanceof ServerPlayerEntity animatedPlayer) { + MobEntity mob = findRescueTarget(serverWorld, attacker, animatedPlayer); + if (mob == null) { + return ActionResult.PASS; + } + + attacker.attack(mob); + return ActionResult.SUCCESS; + } else { + return ActionResult.PASS; + } + } + ); + } + + public static Entity redirectProjectileTarget(ServerWorld world, ProjectileEntity projectile, Entity originalTarget) { + if (projectile.getOwner() instanceof ServerPlayerEntity attacker && originalTarget instanceof ServerPlayerEntity animatedPlayer) { + MobEntity mob = findRescueTarget(world, attacker, animatedPlayer); + return (Entity)(mob == null ? originalTarget : mob); + } else { + return originalTarget; + } + } + + @Nullable + public static MobEntity findDirectDamageRedirectTarget(ServerWorld world, LivingEntity originalTarget, DamageSource source) { + if (originalTarget instanceof ServerPlayerEntity animatedPlayer && source != null && source.isDirect()) { + ServerPlayerEntity attacker; + if (source.getAttacker() instanceof ServerPlayerEntity attributedAttacker) { + attacker = attributedAttacker; + } else { + if (!(source.getSource() instanceof ServerPlayerEntity directAttacker)) { + return null; + } + + attacker = directAttacker; + } + + return findRescueTarget(world, attacker, animatedPlayer); + } else { + return null; + } + } + + @Nullable + private static MobEntity findRescueTarget(ServerWorld world, ServerPlayerEntity attacker, ServerPlayerEntity animatedPlayer) { + UUID instanceId = ServerAnimationController.findActiveInstanceId(world, animatedPlayer.getUuid()); + if (instanceId == null) { + return null; + } + + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + return snapshot != null && !snapshot.actorUuids().contains(attacker.getUuid()) + ? snapshot.actorUuids() + .stream() + .map(world::getEntity) + .filter(MobEntity.class::isInstance) + .map(MobEntity.class::cast) + .filter(Entity::isAlive) + .min(Comparator.comparingDouble(mob -> attacker.squaredDistanceTo(mob)).thenComparingInt(Entity::getId)) + .orElse(null) + : null; + } +} diff --git a/src/main/java/com/nonid/NonAnimationSessionData.java b/src/main/java/com/nonid/NonAnimationSessionData.java new file mode 100644 index 0000000..d08e97c --- /dev/null +++ b/src/main/java/com/nonid/NonAnimationSessionData.java @@ -0,0 +1,111 @@ +package com.nonid; + +import com.nonid.internal.animation.server.AnimationSessionData; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationSessionData( + NonAnimationSessionData.Mode mode, + @Nullable UUID breedingRequestId, + @Nullable NonAnimationSessionData.Pregnancy pregnancy, + @Nullable NonAnimationSessionData.PlayerAttack playerAttack +) implements AnimationSessionData { + public NonAnimationSessionData { + mode = mode == null ? NonAnimationSessionData.Mode.UNSPECIFIED : mode; + } + + public static NonAnimationSessionData voluntary() { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.VOLUNTARY, null, null, null); + } + + public static NonAnimationSessionData attack() { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.ATTACK, null, null, null); + } + + public static NonAnimationSessionData breeding(UUID requestId) { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.UNSPECIFIED, requestId, null, null); + } + + public static NonAnimationSessionData pregnancy(NonAnimationSessionData.Pregnancy pregnancy) { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.PREGNANCY, null, pregnancy, null); + } + + public static NonAnimationSessionData playerAttack(NonAnimationSessionData.PlayerAttack attack) { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.ATTACK, null, null, attack); + } + + public NonAnimationSessionData withPlayerAttack(@Nullable NonAnimationSessionData.PlayerAttack attack) { + return new NonAnimationSessionData(NonAnimationSessionData.Mode.ATTACK, this.breedingRequestId, this.pregnancy, attack); + } + + public boolean isAttack() { + return this.mode == NonAnimationSessionData.Mode.ATTACK; + } + + public boolean isVoluntary() { + return this.mode == NonAnimationSessionData.Mode.VOLUNTARY; + } + + public boolean isPregnancy() { + return this.mode == NonAnimationSessionData.Mode.PREGNANCY; + } + + public enum Mode { + UNSPECIFIED, + VOLUNTARY, + ATTACK, + PREGNANCY; + } + + public record PlayerAttack(UUID victimUuid, UUID finishOwnerUuid, UUID initiatorUuid, Set attackerUuids) { + public PlayerAttack { + if (victimUuid != null && finishOwnerUuid != null && initiatorUuid != null) { + LinkedHashSet attackers = new LinkedHashSet<>(); + if (attackerUuids != null) { + attackers.addAll(attackerUuids); + } + + attackers.remove(victimUuid); + attackers.add(initiatorUuid); + attackerUuids = Set.copyOf(attackers); + } else { + throw new IllegalArgumentException("Player attack participants must not be null"); + } + } + + public NonAnimationSessionData.PlayerAttack withAdditionalAttacker(UUID attackerUuid) { + LinkedHashSet attackers = new LinkedHashSet<>(this.attackerUuids); + if (attackerUuid != null && !attackerUuid.equals(this.victimUuid)) { + attackers.add(attackerUuid); + } + + return new NonAnimationSessionData.PlayerAttack(this.victimUuid, this.finishOwnerUuid, attackerUuid, attackers); + } + + public NonAnimationSessionData.PlayerAttack forDefeatedTail(UUID victim) { + return new NonAnimationSessionData.PlayerAttack(victim, this.finishOwnerUuid, this.initiatorUuid, this.attackerUuids); + } + } + + public record Pregnancy( + Identifier sourceEntityTypeId, + Identifier birthEntityTypeId, + String birthMode, + int offspringCount, + @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + public Pregnancy { + if (sourceEntityTypeId == null) { + throw new IllegalArgumentException("sourceEntityTypeId must not be null"); + } + + birthEntityTypeId = birthEntityTypeId == null ? sourceEntityTypeId : birthEntityTypeId; + birthMode = birthMode == null ? "direct" : birthMode; + offspringCount = Math.max(1, offspringCount); + variantData = NeedsOfNature.normalizePregnancyVariantData(variantData); + } + } +} diff --git a/src/main/java/com/nonid/NonApiInternals.java b/src/main/java/com/nonid/NonApiInternals.java new file mode 100644 index 0000000..2538743 --- /dev/null +++ b/src/main/java/com/nonid/NonApiInternals.java @@ -0,0 +1,358 @@ +package com.nonid; + +import com.nonid.api.NonAccessoryEffectsSnapshot; +import com.nonid.api.NonChangeSource; +import com.nonid.api.NonDestroyedSkinSnapshot; +import com.nonid.api.NonEvents; +import com.nonid.api.NonGender; +import com.nonid.api.NonLiquidComposition; +import com.nonid.api.NonLiquidTankSnapshot; +import com.nonid.api.NonMessSnapshot; +import com.nonid.api.NonMessType; +import com.nonid.api.NonPlayerStateSnapshot; +import com.nonid.api.NonPregnancySnapshot; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonApiInternals { + private static final int DESTROYED_SKIN_MAX_DAMAGE = 10; + + private NonApiInternals() { + } + + public static NonPlayerStateSnapshot snapshotPlayerState(PlayerEntity player) { + return new NonPlayerStateSnapshot( + getGender(player), + snapshotLiquid(player), + snapshotMess(player), + snapshotDestroyedSkin(player), + snapshotPregnancy(player), + snapshotAccessoryEffects(player) + ); + } + + public static NonGender getGender(PlayerEntity player) { + return player instanceof GenderHolder holder ? NonGender.fromMask(holder.getGenderMask()) : NonGender.NONE; + } + + public static boolean setGender(ServerPlayerEntity player, NonGender gender, NonChangeSource source) { + if (player == null || gender == null || gender == NonGender.NONE) { + return false; + } + + if (!canChangeGender(player, source)) { + return false; + } + + int before = player instanceof GenderHolder holder ? holder.getGenderMask() & 3 : 0; + int applied = NonGenderSystem.setPlayerGenderFromCommand(player, gender.mask(), source); + return (before & 3) != (applied & 3); + } + + public static boolean canChangeGender(ServerPlayerEntity player, NonChangeSource source) { + if (player != null && source != null) { + return switch (source) { + case API, COMMAND, ITEM, JOIN_SELECTION, SYSTEM -> true; + case CLIENT_PREFERENCE -> NonGenderSystem.canClientChangeGenderFreely(); + }; + } else { + return false; + } + } + + public static NonLiquidTankSnapshot snapshotLiquid(PlayerEntity player) { + return player instanceof LiquidHolder holder + ? new NonLiquidTankSnapshot( + Math.max(0, holder.getLiquidStored()), + Math.max(0, holder.getLiquidCapacity()), + toApiLiquidComposition(holder.getLiquidComposition()), + holder.getLiquidEntityTypeId(), + NonLiquidSystem.resolveLiquidTintRgb(holder) + ) + : new NonLiquidTankSnapshot(0, 0, NonLiquidComposition.EMPTY, null, 16777215); + } + + public static int addLiquid(ServerPlayerEntity player, @Nullable Identifier sourceEntityType, int amountMl) { + if (!NeedsOfNature.getConfig().isLiquidTankEnabled()) { + return 0; + } + + if (player != null && player instanceof LiquidHolder holder && amountMl > 0) { + int modifiedAmount = ((NonEvents.ModifyLiquidGain)NonEvents.MODIFY_LIQUID_GAIN.invoker()).modifyLiquidGain(player, sourceEntityType, amountMl); + if (modifiedAmount <= 0) { + return 0; + } + + int added = addLiquidWithComposition(holder, sourceEntityType, modifiedAmount); + if (added > 0) { + NonPregnancyConceptionSystem.recordRecentDonor(holder, sourceEntityType, null); + NeedsOfNature.syncLiquidState(player); + } + + return added; + } else { + return 0; + } + } + + public static int drainLiquid(ServerPlayerEntity player, int amountMl) { + if (player != null && player instanceof LiquidHolder holder && amountMl > 0) { + int before = Math.max(0, holder.getLiquidStored()); + int removed = Math.min(before, amountMl); + if (removed <= 0) { + return 0; + } + + int next = before - removed; + holder.setLiquidStored(next); + if (next <= 0) { + holder.clearLiquidComposition(); + } + + NeedsOfNature.syncLiquidState(player); + return removed; + } else { + return 0; + } + } + + public static NonMessSnapshot snapshotMess(PlayerEntity player) { + return player instanceof MessHolder holder + ? new NonMessSnapshot( + NonMessSystem.clampMess(holder.getVMess()), NonMessSystem.clampMess(holder.getAMess()), NonMessSystem.clampMess(holder.getMMess()) + ) + : new NonMessSnapshot(0, 0, 0); + } + + public static boolean addMess(ServerPlayerEntity player, NonMessType type, int amount) { + return changeMess(player, type, Math.max(0, amount)); + } + + public static boolean cleanMess(ServerPlayerEntity player, NonMessType type, int amount) { + return changeMess(player, type, -Math.max(0, amount)); + } + + public static boolean clearMess(ServerPlayerEntity player) { + if (player != null && player instanceof MessHolder holder) { + boolean changed = holder.getVMess() != 0 || holder.getAMess() != 0 || holder.getMMess() != 0; + NonMessSystem.clearMess(player); + return changed; + } else { + return false; + } + } + + public static NonDestroyedSkinSnapshot snapshotDestroyedSkin(PlayerEntity player) { + return player instanceof DestroyedSkinHolder holder + ? new NonDestroyedSkinSnapshot( + Math.max(0, Math.min(4, holder.getDestroyedSkinStage())), Math.max(0, Math.min(10, holder.getDestroyedSkinDamage())), 10 + ) + : new NonDestroyedSkinSnapshot(0, 0, 10); + } + + public static boolean damageDestroyedSkin(ServerPlayerEntity player, int amount) { + return changeDestroyedSkinDamage(player, Math.max(0, amount)); + } + + public static boolean repairDestroyedSkin(ServerPlayerEntity player, int amount) { + return changeDestroyedSkinDamage(player, -Math.max(0, amount)); + } + + public static NonPregnancySnapshot snapshotPregnancy(PlayerEntity player) { + return player instanceof PregnancyHolder holder + ? new NonPregnancySnapshot(holder.getPregnantEntityTypeId(), holder.getPregnancyEndTick(), holder.isPregnancyPendingHatch()) + : new NonPregnancySnapshot(null, 0L, false); + } + + public static boolean beginPregnancy(ServerPlayerEntity player, Identifier entityTypeId, long endTick) { + if (!NeedsOfNature.getConfig().isPregnancyEnabled()) { + return false; + } else if (player != null && entityTypeId != null) { + NeedsOfNature.beginPregnancy(player, entityTypeId, Math.max(1L, endTick)); + return true; + } else { + return false; + } + } + + public static boolean clearPregnancy(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + boolean hadPregnancy = player instanceof PregnancyHolder holder && holder.getPregnantEntityTypeId() != null; + NeedsOfNature.clearPregnancyState(player, true); + return hadPregnancy; + } + + public static NonAccessoryEffectsSnapshot snapshotAccessoryEffects(PlayerEntity player) { + NonAccessoryEffects effects = player instanceof LivingEntity living ? NonTrinketsIntegration.getAccessoryEffects(living) : NonAccessoryEffects.NEUTRAL; + return new NonAccessoryEffectsSnapshot( + effects.liquidDecayMultiplier(), + effects.equalizeLiquidDecayContext(), + effects.playerEnergyGainMultiplier(), + effects.liquidCapacityAdd(), + effects.liquidGainMultiplier(), + effects.filledEffectMultiplier(), + effects.pregnancyChanceMultiplier(), + effects.pregnancyDurationMultiplier(), + effects.messGainMultiplier(), + effects.destroyedSkinDamageMultiplier(), + effects.attackEscapeHitsAdd(), + effects.attackEscapeDamageMultiplier(), + effects.playerEnergyAuraMultiplier(), + effects.nearAnimationMobEnergyMultiplier() + ); + } + + public static boolean isMessSystemEnabled() { + return NeedsOfNature.getConfig().isMessSystemEnabled(); + } + + public static boolean isDestroyedSkinSystemEnabled() { + return NeedsOfNature.getConfig().isDestroyedSkinSystemEnabled(); + } + + public static boolean isLiquidTankEnabled() { + return NeedsOfNature.getConfig().isLiquidTankEnabled(); + } + + public static boolean isPregnancyEnabled() { + return NeedsOfNature.getConfig().isPregnancyEnabled(); + } + + public static void fireLiquidChanged(ServerPlayerEntity player) { + if (player != null) { + ((NonEvents.LiquidTankChanged)NonEvents.LIQUID_TANK_CHANGED.invoker()).onLiquidTankChanged(player, snapshotLiquid(player)); + } + } + + public static void fireMessChanged(ServerPlayerEntity player) { + if (player != null) { + ((NonEvents.MessChanged)NonEvents.MESS_CHANGED.invoker()).onMessChanged(player, snapshotMess(player)); + } + } + + public static void fireDestroyedSkinChanged(ServerPlayerEntity player) { + if (player != null) { + ((NonEvents.DestroyedSkinChanged)NonEvents.DESTROYED_SKIN_CHANGED.invoker()).onDestroyedSkinChanged(player, snapshotDestroyedSkin(player)); + } + } + + public static void firePregnancyChanged(ServerPlayerEntity player) { + if (player != null) { + ((NonEvents.PregnancyChanged)NonEvents.PREGNANCY_CHANGED.invoker()).onPregnancyChanged(player, snapshotPregnancy(player)); + } + } + + public static void fireGenderChanged(ServerPlayerEntity player, int mask, NonChangeSource source) { + if (player != null) { + ((NonEvents.PlayerGenderChanged)NonEvents.PLAYER_GENDER_CHANGED.invoker()).onPlayerGenderChanged(player, NonGender.fromMask(mask), source); + } + } + + private static int addLiquidWithComposition(LiquidHolder holder, @Nullable Identifier sourceEntityType, int amount) { + int stored = Math.max(0, holder.getLiquidStored()); + int capacity = Math.max(0, holder.getLiquidCapacity()); + int room = Math.max(0, capacity - stored); + int added = Math.min(room, amount); + if (added <= 0) { + return 0; + } + + if (stored <= 0) { + if (sourceEntityType != null) { + holder.setLiquidCompositionEntity(sourceEntityType); + } else { + holder.setLiquidCompositionMixed(); + } + } else if (holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY) { + Identifier existingType = holder.getLiquidEntityTypeId(); + if (sourceEntityType == null || !sourceEntityType.equals(existingType)) { + holder.setLiquidCompositionMixed(); + } + } else if (holder.getLiquidComposition() != LiquidHolder.LiquidComposition.MIXED) { + holder.setLiquidCompositionMixed(); + } + + holder.setLiquidStored(stored + added); + return added; + } + + private static boolean changeMess(ServerPlayerEntity player, NonMessType type, int delta) { + if (!NeedsOfNature.getConfig().isMessSystemEnabled()) { + return false; + } + + if (player != null && type != null && delta != 0 && player instanceof MessHolder holder) { + int before = switch (type) { + case V -> holder.getVMess(); + case A -> holder.getAMess(); + case M -> holder.getMMess(); + }; + int after = NonMessSystem.clampMess(before + delta); + if (after == before) { + return false; + } + + switch (type) { + case V: + holder.setVMess(after); + break; + case A: + holder.setAMess(after); + break; + case M: + holder.setMMess(after); + } + + NonMessSystem.broadcastMessState(player); + return true; + } else { + return false; + } + } + + private static boolean changeDestroyedSkinDamage(ServerPlayerEntity player, int delta) { + if (!NeedsOfNature.getConfig().isDestroyedSkinSystemEnabled()) { + return false; + } + + if (player != null && delta != 0 && player instanceof DestroyedSkinHolder holder) { + int before = Math.max(0, Math.min(10, holder.getDestroyedSkinDamage())); + int after = Math.max(0, Math.min(10, before + delta)); + if (after == before) { + return false; + } + + holder.setDestroyedSkinDamage(after); + NonDestroyedSkinSystem.setDestroyedSkinStage(player, destroyedSkinStageForDamage(after)); + return true; + } else { + return false; + } + } + + private static int destroyedSkinStageForDamage(int damage) { + if (damage >= 10) { + return 4; + } else if (damage >= 7) { + return 3; + } else if (damage >= 4) { + return 2; + } else { + return damage >= 1 ? 1 : 0; + } + } + + private static NonLiquidComposition toApiLiquidComposition(LiquidHolder.LiquidComposition composition) { + return switch (composition) { + case ENTITY -> NonLiquidComposition.ENTITY; + case MIXED -> NonLiquidComposition.MIXED; + case EMPTY -> NonLiquidComposition.EMPTY; + }; + } +} diff --git a/src/main/java/com/nonid/NonAttackSystem.java b/src/main/java/com/nonid/NonAttackSystem.java new file mode 100644 index 0000000..d8e1876 --- /dev/null +++ b/src/main/java/com/nonid/NonAttackSystem.java @@ -0,0 +1,671 @@ +package com.nonid; + +import com.nonid.internal.animation.api.AnimationService; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LazyEntityReference; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.attribute.EntityAttributeInstance; +import net.minecraft.entity.attribute.EntityAttributes; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.mob.Angerable; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.mob.Monster; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryKey; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +final class NonAttackSystem { + private static final float ATTACK_ESCAPE_DAMAGE = 4.0F; + private static final float ATTACK_ESCAPE_MIN_DAMAGE = 1.0F; + private static final float ATTACK_ESCAPE_MIN_HEALTH = 1.0F; + private static final int ATTACK_OUTCOME_FAILSAFE_WINDOW_TICKS = 12000; + private static final int ATTACK_OUTCOME_FAILSAFE_COOLDOWN_TICKS = 12000; + private static final int ATTACK_BLOCK_DEBUG_COOLDOWN_TICKS = 400; + private static final double ATTACK_BLOCK_DEBUG_RADIUS = 24.0; + private static final double POST_ESCAPE_GATHER_RADIUS = 10.0; + private static final float ATTACK_ESCAPE_MIN_HEALTH_FACTOR = 0.3F; + private static final long DEFEATED_EASE_DURATION_TICKS = 300L; + private static final float DEFEATED_MAX_STEP_MULTIPLIER = 4.0F; + private static final float PVP_CRITICAL_HEALTH_THRESHOLD = 0.25F; + private static final float PVP_HEALTHY_DEFENDER_THRESHOLD = 0.75F; + private static final float PVP_DEFENDER_HEAT_NEUTRAL_THRESHOLD = 0.6F; + private static final int PVP_MASH_BASE_HITS = 12; + private static final Map ATTACK_OUTCOME_FAILSAFE = new ConcurrentHashMap<>(); + private static final Map ATTACK_BLOCK_DEBUG = new ConcurrentHashMap<>(); + + private NonAttackSystem() { + } + + static void clearRuntimeState() { + ATTACK_OUTCOME_FAILSAFE.clear(); + ATTACK_BLOCK_DEBUG.clear(); + } + + static boolean canMobAttackPlayer(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + if (player.isSpectator()) { + return false; + } + + if (!player.isCreative()) { + return true; + } + + NonConfig config = NeedsOfNature.getConfig(); + return config != null && config.canAttackCreativePlayers(); + } + + static void setAnimationStartProtection(ServerPlayerEntity player, int ticks) { + if (player != null) { + AnimationService.setAnimationStartProtection(player, ticks); + } + } + + static boolean hasAnimationStartProtection(ServerWorld world, ServerPlayerEntity player) { + return world != null && player != null ? AnimationService.hasAnimationStartProtection(world, player) : false; + } + + static boolean isMobAttackFailsafeActive(ServerWorld world, UUID mobUuid) { + if (world != null && mobUuid != null) { + NonAttackSystem.AttackOutcomeFailsafeState state = ATTACK_OUTCOME_FAILSAFE.get(mobUuid); + if (state == null) { + return false; + } + + if (!state.worldKey.equals(world.getRegistryKey())) { + return false; + } + + long nowTick = world.getTime(); + pruneAttackOutcomeWindow(state, nowTick); + if (state.cooldownUntilTick <= nowTick) { + if (state.recentAttackOutcomeTicks.isEmpty()) { + ATTACK_OUTCOME_FAILSAFE.remove(mobUuid, state); + } + + return false; + } else { + return true; + } + } else { + return false; + } + } + + static int clearAttackOutcomeFailsafeEntries() { + int count = ATTACK_OUTCOME_FAILSAFE.size(); + ATTACK_OUTCOME_FAILSAFE.clear(); + return count; + } + + static String getMobAttackFailsafeDebug(ServerWorld world, UUID mobUuid) { + if (world != null && mobUuid != null) { + NonAttackSystem.AttackOutcomeFailsafeState state = ATTACK_OUTCOME_FAILSAFE.get(mobUuid); + if (state != null && state.worldKey.equals(world.getRegistryKey())) { + long nowTick = world.getTime(); + pruneAttackOutcomeWindow(state, nowTick); + long remaining = Math.max(0L, state.cooldownUntilTick - nowTick); + int recent = state.recentAttackOutcomeTicks.size(); + if (remaining <= 0L && recent <= 0) { + ATTACK_OUTCOME_FAILSAFE.remove(mobUuid, state); + return null; + } else { + return remaining > 0L + ? "attack failsafe: " + NeedsOfNature.formatTicksForDebug(remaining) + " remaining; peaks " + recent + "/" + attackOutcomeFailsafeThreshold() + : "attack failsafe: peaks " + recent + "/" + attackOutcomeFailsafeThreshold() + " in 10m window"; + } + } else { + return null; + } + } else { + return null; + } + } + + static NeedsOfNature.AttackBlockReason getMobAttackBlockReason(ServerWorld world, MobEntity mob, ServerPlayerEntity player) { + if (world != null && mob != null && player != null) { + if (mob.getEntityWorld() != world || player.getEntityWorld() != world) { + return null; + } else if (NeedsOfNature.isPregnancyFriendlyMob(mob)) { + return NeedsOfNature.AttackBlockReason.PREGNANCY_FRIENDLY; + } else if (!isAggressiveThreat(mob, player)) { + return null; + } else { + boolean mobBusy = NeedsOfNature.isMobGatheringOrActiveWithPlayer(world, mob, player); + boolean playerBusy = NeedsOfNature.hasActivePlayerAnimation(player); + if (mobBusy) { + return NeedsOfNature.AttackBlockReason.GATHERING_PAIR; + } else { + return playerBusy && mob instanceof EnergyHolder holder && holder.getEnergy() > 70 + ? NeedsOfNature.AttackBlockReason.ENERGY_ABOVE_70_PLAYER_BUSY + : null; + } + } + } else { + return null; + } + } + + static void onMobAttackBlocked(ServerWorld world, MobEntity mob, ServerPlayerEntity player, NeedsOfNature.AttackBlockReason reason) { + if (world != null && mob != null && player != null && reason != null) { + if (reason == NeedsOfNature.AttackBlockReason.PREGNANCY_FRIENDLY) { + if (mob.getTarget() == player) { + mob.setTarget(null); + } + + mob.getNavigation().stop(); + if (mob instanceof Angerable angerable) { + LazyEntityReference angryAt = angerable.getAngryAt(); + UUID angryAtUuid = angryAt == null ? null : angryAt.getUuid(); + if (angryAtUuid == null || angryAtUuid.equals(player.getUuid())) { + angerable.stopAnger(); + } + } + } + + long now = world.getTime(); + RegistryKey worldKey = world.getRegistryKey(); + UUID mobUuid = mob.getUuid(); + NonAttackSystem.AttackBlockDebugState current = ATTACK_BLOCK_DEBUG.get(mobUuid); + if (current == null || !current.worldKey.equals(worldKey) || now >= current.nextTick) { + ATTACK_BLOCK_DEBUG.put(mobUuid, new NonAttackSystem.AttackBlockDebugState(worldKey, now + 400L)); + Text mobName = Text.translatable(mob.getType().getTranslationKey()); + Text playerName = player.getName(); + + Text message = switch (reason) { + case PREGNANCY_FRIENDLY -> Text.translatable("debug.needsofnature.attack_blocked_pregnancy_friendly", new Object[]{mobName, playerName}); + case GATHERING_PAIR -> Text.translatable("debug.needsofnature.attack_blocked_gathering_pair", new Object[]{mobName, playerName}); + case ENERGY_ABOVE_70_PLAYER_BUSY -> Text.translatable("debug.needsofnature.attack_blocked_energy_busy", new Object[]{mobName, playerName}); + }; + NeedsOfNature.sendDebugChatToNearby(world, mob, 24.0, NonDebugChatCategory.WARNING, message); + } + } + } + + private static boolean isAggressiveThreat(MobEntity mob, ServerPlayerEntity player) { + if (mob == null || player == null) { + return false; + } + + if (mob instanceof Monster) { + return true; + } + + if (mob instanceof Angerable angerable) { + LazyEntityReference angryAt = angerable.getAngryAt(); + UUID angryAtUuid = angryAt == null ? null : angryAt.getUuid(); + if (angryAtUuid != null && angryAtUuid.equals(player.getUuid())) { + return true; + } + } + + LivingEntity target = mob.getTarget(); + return target != null && target.getUuid().equals(player.getUuid()); + } + + static boolean hasPlayerAndHostileActor(List actors) { + if (actors != null && !actors.isEmpty()) { + boolean hasPlayer = false; + boolean hasHostile = false; + + for (Entity actor : actors) { + if (actor instanceof ServerPlayerEntity) { + hasPlayer = true; + } else if (isHostileOrAngryNeutral(actor)) { + hasHostile = true; + } + + if (hasPlayer && hasHostile) { + return true; + } + } + + return false; + } else { + return false; + } + } + + private static boolean isHostileOrAngryNeutral(Entity entity) { + if (!(entity instanceof LivingEntity)) { + return false; + } else if (entity instanceof Monster) { + return true; + } else { + return entity instanceof Angerable angerable && entity instanceof MobEntity mob + ? angerable.getAngryAt() != null || mob.getTarget() instanceof ServerPlayerEntity + : false; + } + } + + static void recordAttackOutcomeForMobs(ServerWorld world, List actorUuids, String reason) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + long nowTick = world.getTime(); + + for (UUID actorUuid : actorUuids) { + if (world.getEntity(actorUuid) instanceof MobEntity mob && mob.isAlive() && !mob.isRemoved()) { + NonAttackSystem.AttackOutcomeFailsafeState state = ATTACK_OUTCOME_FAILSAFE.computeIfAbsent( + actorUuid, unused -> new NonAttackSystem.AttackOutcomeFailsafeState(worldKey) + ); + if (!state.worldKey.equals(worldKey)) { + state.worldKey = worldKey; + state.recentAttackOutcomeTicks.clear(); + state.cooldownUntilTick = 0L; + } + + if (state.cooldownUntilTick <= nowTick) { + pruneAttackOutcomeWindow(state, nowTick); + state.recentAttackOutcomeTicks.addLast(nowTick); + if (state.recentAttackOutcomeTicks.size() >= attackOutcomeFailsafeThreshold()) { + state.recentAttackOutcomeTicks.clear(); + state.cooldownUntilTick = nowTick + 12000L; + NeedsOfNature.logRuntime( + "[NoN] Attack peak failsafe armed for mob={} in world={} reason={} untilTick={}", + mob.getUuid(), + worldKey.getValue(), + reason, + state.cooldownUntilTick + ); + } + } + } + } + } + } + + static void startNearbyPostEscapeGathers(ServerWorld world, ServerPlayerEntity player, @Nullable List escapedActorUuids) { + if (world != null && player != null && player.getEntityWorld() == world) { + if (canMobAttackPlayer(player)) { + if (player.isAlive() && !player.isRemoved()) { + if (!NonGatherSystem.isAutomaticMobPlayerStartBlockedByMount(player)) { + int maxMobs = NeedsOfNature.getConfig().getPostEscapeGatherMaxMobs(); + if (maxMobs > 0) { + Box searchBox = player.getBoundingBox().expand(10.0); + List candidates = world.getEntitiesByClass( + MobEntity.class, searchBox, mobx -> isPostEscapeGatherCandidate(world, player, mobx, escapedActorUuids) + ); + if (!candidates.isEmpty()) { + candidates.sort(Comparator.comparingDouble(player::squaredDistanceTo)); + int started = 0; + + for (MobEntity mob : candidates) { + if (started >= maxMobs) { + break; + } + + List actors = new ArrayList<>(2); + actors.add(mob); + actors.add(player); + actors.sort(Comparator.comparingInt(Entity::getId)); + NonGatherSystem.GatherCandidate gatherCandidate = NonGatherSystem.findGatherCandidate(world, actors, true, mob, player); + if (gatherCandidate != null) { + boolean gatherStarted = NonGatherSystem.startMobPlayerGather( + world, mob, player, gatherCandidate.animationId(), gatherCandidate.actorKeys(), gatherCandidate.stages() + ); + if (gatherStarted) { + started++; + NeedsOfNature.logRuntime( + "Post-escape gather started: mobType={} player={} anim={} energy={}", + Registries.ENTITY_TYPE.getId(mob.getType()), + player.getName().getString(), + gatherCandidate.animationId(), + ((EnergyHolder)mob).getEnergy() + ); + } + } + } + + if (started > 0) { + NeedsOfNature.sendDebugChat( + player, NonDebugChatCategory.INFO, Text.translatable("debug.needsofnature.post_escape_gather_started", new Object[]{started}) + ); + } + } + } + } + } + } + } + } + + private static boolean isPostEscapeGatherCandidate(ServerWorld world, ServerPlayerEntity player, MobEntity mob, @Nullable List escapedActorUuids) { + if (mob == null || mob.getEntityWorld() != world) { + return false; + } + + if (!mob.isAlive() || mob.isRemoved()) { + return false; + } + + if (!NonMobGatherEligibility.canInitiateAutoGather(mob)) { + return false; + } + + if (mob instanceof EnergyHolder holder) { + if (holder.getEnergy() < 70) { + return false; + } else if (NonMobEnergySystem.isEnergyAttackStabilized(mob) && holder.getEnergy() >= 200) { + return false; + } else if (!NonMobEnergySystem.hasGenericAnimationSupport(mob.getType(), mob.isBaby())) { + return false; + } else if (NeedsOfNature.isActorPendingOrActive(world, mob.getUuid())) { + return false; + } else if (NeedsOfNature.isMobAttackFailsafeActive(world, mob.getUuid())) { + return false; + } else if (!mob.canSee(player)) { + return false; + } else { + return escapedActorUuids != null && escapedActorUuids.contains(mob.getUuid()) ? true : mob.squaredDistanceTo(player) <= 100.0; + } + } else { + return false; + } + } + + private static int attackOutcomeFailsafeThreshold() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 3 : config.getAttackOutcomeFailsafeThreshold(); + } + + private static void pruneAttackOutcomeWindow(NonAttackSystem.AttackOutcomeFailsafeState state, long nowTick) { + if (state != null) { + long oldestAllowedTick = nowTick - 12000L; + + while (!state.recentAttackOutcomeTicks.isEmpty() && state.recentAttackOutcomeTicks.peekFirst() <= oldestAllowedTick) { + state.recentAttackOutcomeTicks.removeFirst(); + } + } + } + + static void pruneAttackOutcomeFailsafe(ServerWorld world, long nowTick) { + if (world != null && !ATTACK_OUTCOME_FAILSAFE.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + ATTACK_OUTCOME_FAILSAFE.entrySet().removeIf(entry -> { + NonAttackSystem.AttackOutcomeFailsafeState state = entry.getValue(); + if (!state.worldKey.equals(worldKey)) { + return false; + } + + pruneAttackOutcomeWindow(state, nowTick); + return state.cooldownUntilTick <= nowTick && state.recentAttackOutcomeTicks.isEmpty(); + }); + } + } + + static void pruneAttackBlockDebug(ServerWorld world, long nowTick) { + if (world != null && !ATTACK_BLOCK_DEBUG.isEmpty()) { + RegistryKey worldKey = world.getRegistryKey(); + ATTACK_BLOCK_DEBUG.entrySet().removeIf(entry -> { + NonAttackSystem.AttackBlockDebugState state = entry.getValue(); + return !state.worldKey.equals(worldKey) ? false : state.nextTick <= nowTick; + }); + } + } + + static void applyAttackEscapeDamage(ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids) { + applyAttackEscapeDamage(world, player, actorUuids, null); + } + + static void applyAttackEscapeDamage(ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids, @Nullable UUID preferredAttackerUuid) { + if (world != null && player != null) { + if (player.isAlive()) { + float health = player.getHealth(); + if (!(health <= 1.0F)) { + float maxRaw = health - 1.0F; + if (!(maxRaw <= 0.0F)) { + LivingEntity attacker = resolveAttackEscapeAttacker(world, player, actorUuids, preferredAttackerUuid); + float rawDamage = resolveAttackEscapeRawDamage(attacker); + rawDamage *= (float)NonTrinketsIntegration.getAccessoryEffects(player).attackEscapeDamageMultiplier(); + rawDamage = Math.min(rawDamage, maxRaw); + if (!(rawDamage <= 0.0F)) { + if (attacker instanceof ServerPlayerEntity attackingPlayer) { + NonPlayerAttackSystem.applyPlayerAttributedEscapeDamage(world, player, attackingPlayer, rawDamage); + } else { + DamageSource source = resolveAttackEscapeDamageSource(player, attacker); + player.damage(world, source, rawDamage); + } + + float after = player.getHealth(); + float dealt = health - after; + if (dealt < 1.0F && after > 1.0F) { + float extra = Math.min(1.0F - dealt, after - 1.0F); + if (extra > 0.0F) { + player.setHealth(after - extra); + } + } + } + } + } + } + } + } + + @Nullable + private static LivingEntity resolveAttackEscapeAttacker( + ServerWorld world, ServerPlayerEntity player, @Nullable List actorUuids, @Nullable UUID preferredAttackerUuid + ) { + if (world != null && player != null) { + if (preferredAttackerUuid != null && !preferredAttackerUuid.equals(player.getUuid())) { + Entity preferred = world.getEntity(preferredAttackerUuid); + if (preferred == null) { + preferred = world.getServer().getPlayerManager().getPlayer(preferredAttackerUuid); + } + + if (preferred instanceof LivingEntity living && living.isAlive() && !living.isRemoved()) { + return living; + } + } + + if (actorUuids != null && !actorUuids.isEmpty()) { + for (UUID actorUuid : actorUuids) { + if (actorUuid != null + && !actorUuid.equals(player.getUuid()) + && world.getEntity(actorUuid) instanceof LivingEntity living + && living.isAlive() + && !living.isRemoved()) { + return living; + } + } + + return null; + } else { + return null; + } + } else { + return null; + } + } + + private static float resolveAttackEscapeRawDamage(@Nullable LivingEntity attacker) { + float baseDamage = 4.0F; + if (attacker != null) { + EntityAttributeInstance attackDamage = attacker.getAttributeInstance(EntityAttributes.ATTACK_DAMAGE); + if (attackDamage != null) { + double attributeDamage = attackDamage.getValue(); + if (Double.isFinite(attributeDamage) && attributeDamage > 0.0) { + baseDamage = (float)attributeDamage; + } + } + } + + if (!Float.isFinite(baseDamage)) { + baseDamage = 4.0F; + } + + return Math.max(1.0F, baseDamage); + } + + private static DamageSource resolveAttackEscapeDamageSource(ServerPlayerEntity player, @Nullable LivingEntity attacker) { + if (player == null) { + throw new IllegalArgumentException("player"); + } else { + return attacker instanceof MobEntity mob ? player.getDamageSources().mobAttack(mob) : player.getDamageSources().generic(); + } + } + + static float resolveServerAttackEscapeStep(ServerWorld world, ServerPlayerEntity victim, boolean defeated, long contestStartTick) { + if (world != null && victim != null) { + float base = 1.0F / resolveAttackEscapeHits(victim); + float healthFactor = resolveAttackHealthFactor(victim); + float energyFactor = resolveAttackEnergyFactor(victim); + float randomScale = healthFactor >= 0.999F ? 1.0F : 0.5F + world.getRandom().nextFloat(); + if (!defeated) { + return Math.max(0.0F, base * healthFactor * energyFactor * randomScale); + } + + float ease = MathHelper.clamp((float)Math.max(0L, world.getTime() - contestStartTick) / 300.0F, 0.0F, 1.0F); + float adjustedHealth = MathHelper.lerp(ease, healthFactor, 1.0F); + float adjustedEnergy = MathHelper.lerp(ease, energyFactor, 1.0F); + float adjustedRandom = MathHelper.lerp(ease, randomScale, 1.0F); + float multiplier = MathHelper.lerp(ease, 1.0F, 4.0F); + return Math.max(0.0F, base * adjustedHealth * adjustedEnergy * adjustedRandom * multiplier); + } else { + return 0.0F; + } + } + + static NonAttackSystem.PvpMashFactors resolveServerPlayerContestStep( + ServerWorld world, ServerPlayerEntity participant, boolean defender, boolean defeated, long contestStartTick + ) { + if (world != null && participant != null) { + float base = 0.083333336F; + float healthMultiplier = resolvePvpCriticalHealthMultiplier(participant); + float heatMultiplier = resolvePvpHeatMultiplier(participant, defender); + float healthyDefenderMultiplier = defender ? resolvePvpHealthyDefenderMultiplier(participant) : 1.0F; + float defeatedEaseMultiplier = 1.0F; + if (defender && defeated) { + float ease = MathHelper.clamp((float)Math.max(0L, world.getTime() - contestStartTick) / 300.0F, 0.0F, 1.0F); + healthMultiplier = MathHelper.lerp(ease, healthMultiplier, 1.0F); + heatMultiplier = MathHelper.lerp(ease, heatMultiplier, 1.0F); + healthyDefenderMultiplier = MathHelper.lerp(ease, healthyDefenderMultiplier, 1.0F); + defeatedEaseMultiplier = MathHelper.lerp(ease, 1.0F, 4.0F); + } + + float step = base * healthMultiplier * heatMultiplier * healthyDefenderMultiplier * defeatedEaseMultiplier; + return new NonAttackSystem.PvpMashFactors(Math.max(0.0F, step), healthMultiplier, heatMultiplier, healthyDefenderMultiplier, defeatedEaseMultiplier); + } else { + return NonAttackSystem.PvpMashFactors.none(); + } + } + + private static int resolveAttackEscapeHits(ServerPlayerEntity victim) { + int hits = NeedsOfNature.getConfig().getAttackEscapeHits() + NonTrinketsIntegration.getAccessoryEffects(victim).attackEscapeHitsAdd(); + return Math.max(1, Math.min(50, hits)); + } + + private static float resolvePvpCriticalHealthMultiplier(ServerPlayerEntity player) { + float healthRatio = resolveHealthRatio(player); + if (healthRatio >= 0.25F) { + return 1.0F; + } + + float criticalStrength = NeedsOfNature.getConfig().getPvpCriticalHealthMashStrengthPercent() / 100.0F; + float progress = MathHelper.clamp(healthRatio / 0.25F, 0.0F, 1.0F); + return MathHelper.lerp(progress, criticalStrength, 1.0F); + } + + private static float resolvePvpHealthyDefenderMultiplier(ServerPlayerEntity player) { + float healthRatio = resolveHealthRatio(player); + if (healthRatio <= 0.75F) { + return 1.0F; + } + + float progress = MathHelper.clamp((healthRatio - 0.75F) / 0.25F, 0.0F, 1.0F); + float fullHealthMultiplier = 1.0F + NeedsOfNature.getConfig().getPvpFullHealthEscapeBonusPercent() / 100.0F; + return MathHelper.lerp(progress, 1.0F, fullHealthMultiplier); + } + + private static float resolvePvpHeatMultiplier(ServerPlayerEntity player, boolean defender) { + float energyRatio = resolveEnergyRatio(player); + NonConfig config = NeedsOfNature.getConfig(); + if (!defender) { + float zeroHeatStrength = config.getPvpZeroHeatAttackStrengthPercent() / 100.0F; + return MathHelper.lerp(energyRatio, zeroHeatStrength, 1.0F); + } + + if (energyRatio <= 0.6F) { + return 1.0F; + } + + float progress = MathHelper.clamp((energyRatio - 0.6F) / 0.39999998F, 0.0F, 1.0F); + float maxHeatStrength = config.getPvpMaxHeatEscapeStrengthPercent() / 100.0F; + return MathHelper.lerp(progress, 1.0F, maxHeatStrength); + } + + private static float resolveHealthRatio(ServerPlayerEntity player) { + float maxHealth = player.getMaxHealth(); + return Float.isFinite(maxHealth) && !(maxHealth <= 0.0F) ? MathHelper.clamp(player.getHealth() / maxHealth, 0.0F, 1.0F) : 0.0F; + } + + private static float resolveEnergyRatio(ServerPlayerEntity player) { + if (player instanceof EnergyHolder holder) { + int maxEnergy = Math.max(1, holder.getMaxEnergy()); + return MathHelper.clamp((float)holder.getEnergy() / maxEnergy, 0.0F, 1.0F); + } else { + return 1.0F; + } + } + + private static float resolveAttackHealthFactor(ServerPlayerEntity victim) { + float maxHealth = victim.getMaxHealth(); + float minHealth = 0.5F; + if (maxHealth <= minHealth) { + return 0.3F; + } + + float raw = MathHelper.clamp((victim.getHealth() - minHealth) / (maxHealth - minHealth), 0.0F, 1.0F); + float impact = NeedsOfNature.getConfig().getAttackEscapeDamageDifficultyPercent() / 100.0F; + return MathHelper.clamp(1.0F - (1.0F - raw) * impact, 0.3F, 1.0F); + } + + private static float resolveAttackEnergyFactor(ServerPlayerEntity victim) { + if (victim instanceof EnergyHolder holder) { + int max = Math.max(1, holder.getMaxEnergy()); + float energy = Math.max(0.0F, Math.min(max, holder.getEnergy())); + if (energy <= 100.0F) { + return 1.0F - 0.1F * energy / 100.0F; + } + + float t = Math.min(1.0F, (energy - 100.0F) / 100.0F); + return Math.max(0.08F, 0.9F - 0.82F * t * t); + } else { + return 1.0F; + } + } + + private record AttackBlockDebugState(RegistryKey worldKey, long nextTick) { + } + + private static final class AttackOutcomeFailsafeState { + RegistryKey worldKey; + final ArrayDeque recentAttackOutcomeTicks = new ArrayDeque<>(); + long cooldownUntilTick = 0L; + + AttackOutcomeFailsafeState(RegistryKey worldKey) { + this.worldKey = worldKey; + } + } + + record PvpMashFactors(float step, float healthMultiplier, float heatMultiplier, float healthyDefenderMultiplier, float defeatedEaseMultiplier) { + private static NonAttackSystem.PvpMashFactors none() { + return new NonAttackSystem.PvpMashFactors(0.0F, 0.0F, 0.0F, 0.0F, 0.0F); + } + } +} diff --git a/src/main/java/com/nonid/NonAuraSystem.java b/src/main/java/com/nonid/NonAuraSystem.java new file mode 100644 index 0000000..2f77395 --- /dev/null +++ b/src/main/java/com/nonid/NonAuraSystem.java @@ -0,0 +1,209 @@ +package com.nonid; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import net.minecraft.entity.Entity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; + +final class NonAuraSystem { + private static final int MESS_AURA_MAX_TOTAL = 15; + private static final double MESS_AURA_MAX_MULTIPLIER = 10.0; + + private NonAuraSystem() { + } + + static void tickPlayerEnergyAura(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null && player.isAlive() && !player.isRemoved()) { + if (player instanceof EnergyHolder playerEnergyHolder) { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null) { + int pulseTicks = config.getPlayerEnergyAuraPulseTicks(); + if (pulseTicks > 0 && world.getTime() % pulseTicks == 0L) { + int playerEnergy = playerEnergyHolder.getEnergy(); + int destroyedStage = config.isDestroyedSkinSystemEnabled() ? NeedsOfNature.getDestroyedSkinStageForPlayer(player) : 0; + int messTotal = config.isMessSystemEnabled() ? NeedsOfNature.getTotalMessForPlayer(player) : 0; + if (config.isMessSystemEnabled()) { + NeedsOfNature.checkBadIdeaBeaconAdvancement(player); + } + + double auraMultiplier = getCombinedPlayerAuraMultiplier(config, playerEnergy, destroyedStage, messTotal) + * NonTrinketsIntegration.getAccessoryEffects(player).playerEnergyAuraMultiplier(); + if (Double.isFinite(auraMultiplier) && !(auraMultiplier <= 1.0)) { + double radius = getCombinedPlayerAuraRadius(config, playerEnergy, destroyedStage, messTotal); + if (Double.isFinite(radius) && !(radius <= 0.0)) { + double radiusSq = radius * radius; + Box searchBox = player.getBoundingBox().expand(radius); + List mobs = world.getEntitiesByClass( + MobEntity.class, + searchBox, + mobx -> mobx.isAlive() && !mobx.isRemoved() && mobx.getEntityWorld() == world && mobx.squaredDistanceTo(player) <= radiusSq + ); + if (!mobs.isEmpty()) { + for (MobEntity mob : mobs) { + if (mob instanceof EnergyHolder holder) { + holder.ensureEnergyInitialized(world); + int currentEnergy = holder.getEnergy(); + int maxEnergy = holder.getMaxEnergy(); + if (currentEnergy >= maxEnergy) { + holder.setEnergyAuraCarry(0.0); + } else if (hasPairAnimationWithPlayer(mob, player)) { + double baseGain = getExpectedMobEnergyGainPerPulse(world, mob, holder, pulseTicks); + if (Double.isFinite(baseGain) && !(baseGain <= 0.0)) { + double bonusGain = baseGain * (auraMultiplier - 1.0); + double totalGain = holder.getEnergyAuraCarry() + bonusGain; + int rawApplied = MathHelper.floor(totalGain); + if (rawApplied <= 0) { + holder.setEnergyAuraCarry(totalGain); + } else { + int appliedGain = Math.min(rawApplied, maxEnergy - currentEnergy); + if (appliedGain <= 0) { + holder.setEnergyAuraCarry(0.0); + } else { + holder.setEnergy(currentEnergy + appliedGain); + holder.setEnergyAuraCarry(appliedGain < rawApplied ? 0.0 : totalGain - appliedGain); + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + static NonAuraSystem.PlayerEnergyAuraDebug getPlayerEnergyAuraDebug(ServerWorld world, MobEntity mob, EnergyHolder holder) { + if (world != null && mob != null && holder != null) { + NonConfig config = NeedsOfNature.getConfig(); + if (config == null) { + return null; + } + + NonAuraSystem.PlayerEnergyAuraDebug best = null; + + for (ServerPlayerEntity player : world.getPlayers()) { + if (player != null && player.isAlive() && !player.isRemoved() && player instanceof EnergyHolder playerEnergyHolder) { + int playerEnergy = playerEnergyHolder.getEnergy(); + int destroyedStage = config.isDestroyedSkinSystemEnabled() ? NeedsOfNature.getDestroyedSkinStageForPlayer(player) : 0; + int messTotal = config.isMessSystemEnabled() ? NeedsOfNature.getTotalMessForPlayer(player) : 0; + double radius = getCombinedPlayerAuraRadius(config, playerEnergy, destroyedStage, messTotal); + if (Double.isFinite(radius) && !(radius <= 0.0) && !(mob.squaredDistanceTo(player) > radius * radius) && hasPairAnimationWithPlayer(mob, player) + ) + { + double totalMultiplier = getCombinedPlayerAuraMultiplier(config, playerEnergy, destroyedStage, messTotal) + * NonTrinketsIntegration.getAccessoryEffects(player).playerEnergyAuraMultiplier(); + if (Double.isFinite(totalMultiplier) && !(totalMultiplier <= 1.0) && (best == null || totalMultiplier > best.totalMultiplier())) { + best = new NonAuraSystem.PlayerEnergyAuraDebug(player, totalMultiplier, playerEnergy, destroyedStage, messTotal, radius); + } + } + } + } + + return best; + } else { + return null; + } + } + + private static double getCombinedPlayerAuraMultiplier(NonConfig config, int playerEnergy, int destroyedStage, int messTotal) { + if (config == null) { + return 1.0; + } + + double energyBonus = Math.max(0.0, getPlayerEnergyAuraMultiplier(config, playerEnergy) - 1.0); + double destroyedBonus = Math.max(0.0, getDestroyedSkinAuraMultiplier(config, destroyedStage) - 1.0); + double messBonus = Math.max(0.0, getMessAuraMultiplier(messTotal) - 1.0); + return 1.0 + energyBonus + destroyedBonus + messBonus; + } + + private static double getPlayerEnergyAuraMultiplier(NonConfig config, int playerEnergy) { + if (config == null || playerEnergy < 100) { + return 1.0; + } else if (playerEnergy >= 200) { + return config.getPlayerEnergyAuraMultHigh(); + } else { + return playerEnergy >= 150 ? config.getPlayerEnergyAuraMultMid() : config.getPlayerEnergyAuraMultLow(); + } + } + + private static double getDestroyedSkinAuraMultiplier(NonConfig config, int stage) { + if (config == null) { + return 1.0; + } + + return switch (stage) { + case 1 -> config.getDestroyedSkinAuraMultStage1(); + case 2 -> config.getDestroyedSkinAuraMultStage2(); + case 3 -> config.getDestroyedSkinAuraMultStage3(); + case 4 -> config.getDestroyedSkinAuraMultStage4(); + default -> 1.0; + }; + } + + private static double getMessAuraMultiplier(int messTotal) { + if (messTotal <= 0) { + return 1.0; + } + + double progress = MathHelper.clamp(messTotal / 15.0, 0.0, 1.0); + return MathHelper.lerp(progress, 1.0, 10.0); + } + + private static double getPlayerEnergyAuraRadius(NonConfig config, int playerEnergy) { + if (config != null && playerEnergy >= 100) { + double minRadius = config.getPlayerEnergyAuraRadiusMin(); + double maxRadius = config.getPlayerEnergyAuraRadiusMax(); + double progress = MathHelper.clamp((playerEnergy - 100) / 100.0, 0.0, 1.0); + return MathHelper.lerp(progress, minRadius, maxRadius); + } else { + return 0.0; + } + } + + private static double getCombinedPlayerAuraRadius(NonConfig config, int playerEnergy, int destroyedStage, int messTotal) { + if (config == null) { + return 0.0; + } else if (playerEnergy >= 100) { + return getPlayerEnergyAuraRadius(config, playerEnergy); + } else { + return destroyedStage <= 0 && messTotal <= 0 ? 0.0 : config.getPlayerEnergyAuraRadiusMin(); + } + } + + private static boolean hasPairAnimationWithPlayer(MobEntity mob, ServerPlayerEntity player) { + if (mob != null && player != null) { + List pair = new ArrayList<>(2); + pair.add(mob); + pair.add(player); + pair.sort(Comparator.comparingInt(Entity::getId)); + return NonAnimationMatcher.findAutomatic(player.getEntityWorld(), pair, Set.of(), null, false, null, Set.of()) != null; + } else { + return false; + } + } + + private static double getExpectedMobEnergyGainPerPulse(ServerWorld world, MobEntity mob, EnergyHolder holder, int pulseTicks) { + if (world != null && mob != null && holder != null && pulseTicks > 0) { + double nearAnimationMultiplier = NeedsOfNature.getNearbyVisibleAnimationGainMultiplier(world, mob); + double scale = NonMobEnergySystem.passiveGainScale( + mob, holder.getEnergyGainMultiplier(), holder.getEnergyGainDrift(), nearAnimationMultiplier > 1.0, nearAnimationMultiplier + ); + return scale <= 0.0 ? 0.0 : pulseTicks * scale / 120.0; + } else { + return 0.0; + } + } + + record PlayerEnergyAuraDebug(ServerPlayerEntity player, double totalMultiplier, int playerEnergy, int destroyedSkinStage, int messTotal, double radius) { + } +} diff --git a/src/main/java/com/nonid/NonCommands.java b/src/main/java/com/nonid/NonCommands.java new file mode 100644 index 0000000..cb7cf11 --- /dev/null +++ b/src/main/java/com/nonid/NonCommands.java @@ -0,0 +1,714 @@ +package com.nonid; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.nonid.network.SkinRippedInfoRequestS2CPayload; +import java.util.List; +import java.util.Locale; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.command.argument.EntityArgumentType; +import net.minecraft.entity.Entity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.registry.Registries; +import net.minecraft.server.PlayerConfigEntry; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +final class NonCommands { + private NonCommands() { + } + + static void register() { + CommandRegistrationCallback.EVENT + .register( + (CommandRegistrationCallback)(dispatcher, registryAccess, environment) -> dispatcher.register( + (LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal( + "needsofnature" + ) + .requires(NonCommands::isOpCommandSource)) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("getplayerinfo").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature getplayerinfo ")); + return 0; + } else { + return sendPlayerInfo((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes(ctx -> sendPlayerInfo((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player"))) + ) + )) + .then(CommandManager.literal("resetmobcooldowns").executes(ctx -> resetMobCooldowns((ServerCommandSource)ctx.getSource())))) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("skin").then(messCommand())) + .then( + ((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal("ripped") + .then(CommandManager.literal("info").executes(ctx -> requestRippedSkinInfo((ServerCommandSource)ctx.getSource())))) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("reset").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature skin ripped reset ")); + return 0; + } else { + return resetDestroyedSkinCommand((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> resetDestroyedSkinCommand( + (ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player") + ) + ) + ) + )) + .then( + CommandManager.literal("set") + .then( + ((RequiredArgumentBuilder)CommandManager.argument("stage", IntegerArgumentType.integer(0, 4)).executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature skin ripped set ")); + return 0; + } else { + return setDestroyedSkinStageCommand( + (ServerCommandSource)ctx.getSource(), self, IntegerArgumentType.getInteger(ctx, "stage") + ); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> setDestroyedSkinStageCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + IntegerArgumentType.getInteger(ctx, "stage") + ) + ) + ) + ) + ) + ) + )) + .then( + ((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal("gender") + .requires(NonCommands::isOpCommandSource)) + .then( + CommandManager.literal("get") + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> getGenderCommand((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player")) + ) + ) + )) + .then( + CommandManager.literal("set") + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .then( + CommandManager.argument("gender", StringArgumentType.word()) + .executes( + ctx -> setGenderCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "gender") + ) + ) + ) + ) + )) + .then( + CommandManager.literal("reset") + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes(ctx -> resetGenderCommand((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player"))) + ) + ) + )) + .then( + ((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal("pregnancy") + .then( + CommandManager.literal("cycle") + .then( + CommandManager.literal("set") + .then( + ((RequiredArgumentBuilder)CommandManager.argument("day", IntegerArgumentType.integer(1, 30)) + .executes( + ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError( + Text.literal("控制台用法: /needsofnature pregnancy cycle set ") + ); + return 0; + } else { + return setPregnancyCycleDayCommand( + (ServerCommandSource)ctx.getSource(), self, IntegerArgumentType.getInteger(ctx, "day") + ); + } + } + )) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> setPregnancyCycleDayCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + IntegerArgumentType.getInteger(ctx, "day") + ) + ) + ) + ) + ) + )) + .then( + CommandManager.literal("set") + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .then( + ((RequiredArgumentBuilder)CommandManager.argument("entity", StringArgumentType.string()) + .executes( + ctx -> setPregnancyCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "entity"), + NeedsOfNature.getConfig().getPregnancyDurationMinutes(), + 0 + ) + )) + .then( + ((RequiredArgumentBuilder)CommandManager.argument("minutes", IntegerArgumentType.integer(1, 5256000)) + .executes( + ctx -> setPregnancyCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "entity"), + IntegerArgumentType.getInteger(ctx, "minutes"), + 0 + ) + )) + .then( + CommandManager.argument("spawns", IntegerArgumentType.integer(1, 16)) + .executes( + ctx -> setPregnancyCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + StringArgumentType.getString(ctx, "entity"), + IntegerArgumentType.getInteger(ctx, "minutes"), + IntegerArgumentType.getInteger(ctx, "spawns") + ) + ) + ) + ) + ) + ) + )) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("clear").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature pregnancy clear ")); + return 0; + } else { + return clearPregnancyCommand((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> clearPregnancyCommand((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player")) + ) + ) + )) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("complete").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature pregnancy complete ")); + return 0; + } else { + return completePregnancyCommand((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> completePregnancyCommand((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player")) + ) + ) + )) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("get").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()).sendError(Text.literal("控制台用法: /needsofnature pregnancy get ")); + return 0; + } else { + return sendPregnantEntity((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes(ctx -> sendPregnantEntity((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player"))) + ) + ) + ) + ) + ); + } + + private static LiteralArgumentBuilder messCommand() { + return (LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal("mess") + .requires(NonCommands::isOpCommandSource)) + .then( + ((LiteralArgumentBuilder)CommandManager.literal("clean").executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()).sendError(Text.literal("控制台用法: /needsofnature skin mess clean ")); + return 0; + } else { + return cleanMessCommand((ServerCommandSource)ctx.getSource(), self); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes(ctx -> cleanMessCommand((ServerCommandSource)ctx.getSource(), EntityArgumentType.getPlayer(ctx, "player"))) + ) + )) + .then( + ((LiteralArgumentBuilder)((LiteralArgumentBuilder)((LiteralArgumentBuilder)CommandManager.literal("set") + .then( + CommandManager.argument("v_value", IntegerArgumentType.integer(0, 10)) + .then( + CommandManager.argument("a_value", IntegerArgumentType.integer(0, 10)) + .then( + ((RequiredArgumentBuilder)CommandManager.argument("m_value", IntegerArgumentType.integer(0, 10)) + .executes( + ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature skin mess set ")); + return 0; + } else { + return setMessCommand( + (ServerCommandSource)ctx.getSource(), + self, + IntegerArgumentType.getInteger(ctx, "v_value"), + IntegerArgumentType.getInteger(ctx, "a_value"), + IntegerArgumentType.getInteger(ctx, "m_value") + ); + } + } + )) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> setMessCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + IntegerArgumentType.getInteger(ctx, "v_value"), + IntegerArgumentType.getInteger(ctx, "a_value"), + IntegerArgumentType.getInteger(ctx, "m_value") + ) + ) + ) + ) + ) + )) + .then(messSlotSetCommand("v"))) + .then(messSlotSetCommand("a"))) + .then(messSlotSetCommand("m")) + ); + } + + private static LiteralArgumentBuilder messSlotSetCommand(String slot) { + return (LiteralArgumentBuilder)CommandManager.literal(slot) + .then( + ((RequiredArgumentBuilder)CommandManager.argument("value", IntegerArgumentType.integer(0, 10)).executes(ctx -> { + ServerPlayerEntity self = ((ServerCommandSource)ctx.getSource()).getPlayer(); + if (self == null) { + ((ServerCommandSource)ctx.getSource()) + .sendError(Text.literal("控制台用法: /needsofnature skin mess set " + slot + " ")); + return 0; + } else { + return setMessSlotCommand((ServerCommandSource)ctx.getSource(), self, slot, IntegerArgumentType.getInteger(ctx, "value")); + } + })) + .then( + CommandManager.argument("player", EntityArgumentType.player()) + .executes( + ctx -> setMessSlotCommand( + (ServerCommandSource)ctx.getSource(), + EntityArgumentType.getPlayer(ctx, "player"), + slot, + IntegerArgumentType.getInteger(ctx, "value") + ) + ) + ) + ); + } + + private static int sendPlayerInfo(ServerCommandSource source, ServerPlayerEntity target) { + int result = sendPlayerEnergy(source, target); + result += sendPlayerCooldowns(source, target); + result += sendPlayerContestInfo(source, target); + source.sendFeedback(() -> Text.literal(NonPregnancyConceptionSystem.describeCycle(target)), false); + return result + 1; + } + + private static int sendPlayerContestInfo(ServerCommandSource source, ServerPlayerEntity target) { + List lines = NonPlayerAttackSystem.getContestDebugLines(target); + + for (String line : lines) { + source.sendFeedback(() -> Text.literal(line), false); + } + + return lines.size(); + } + + private static int sendPlayerEnergy(ServerCommandSource source, ServerPlayerEntity target) { + if (target instanceof EnergyHolder holder) { + int current = holder.getEnergy(); + int max = holder.getMaxEnergy(); + source.sendFeedback(() -> Text.literal(target.getName().getString() + " energy: " + current + "/" + max), false); + return current; + } else { + source.sendError(Text.literal("目标没有能量数据。")); + return 0; + } + } + + private static int sendPlayerCooldowns(ServerCommandSource source, ServerPlayerEntity target) { + if (!(target.getEntityWorld() instanceof ServerWorld world)) { + source.sendError(Text.literal("目标不在服务器世界中。")); + return 0; + } else { + List cooldowns = NeedsOfNature.getActivePlayerCooldownDebugLines(world, target); + if (cooldowns.isEmpty()) { + source.sendFeedback(() -> Text.literal(target.getName().getString() + " cooldowns: none"), false); + return 0; + } + + source.sendFeedback(() -> Text.literal(target.getName().getString() + " cooldowns:"), false); + + for (String line : cooldowns) { + source.sendFeedback(() -> Text.literal("- " + line), false); + } + + return cooldowns.size(); + } + } + + private static int requestRippedSkinInfo(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) { + source.sendError(Text.literal("控制台用法: /needsofnature skin ripped info as a player")); + return 0; + } else { + ServerPlayNetworking.send(player, new SkinRippedInfoRequestS2CPayload()); + source.sendFeedback(() -> Text.literal("已请求输出本地撕裂皮肤信息。"), false); + return 1; + } + } + + private static boolean isOpCommandSource(ServerCommandSource source) { + if (source == null) { + return false; + } + + ServerPlayerEntity player = source.getPlayer(); + return player == null ? true : source.getServer().getPlayerManager().isOperator(new PlayerConfigEntry(player.getGameProfile())); + } + + private static int resetMobCooldowns(ServerCommandSource source) { + int failsafeEntries = NeedsOfNature.clearAttackOutcomeFailsafeEntries(); + int mobsReset = 0; + + for (ServerWorld world : source.getServer().getWorlds()) { + for (Entity entity : world.iterateEntities()) { + if (entity instanceof MobEntity mob && mob instanceof EnergyHolder holder) { + holder.onNonAnimationStarted(); + mobsReset++; + } + } + } + + int total = failsafeEntries + mobsReset; + String feedback = "Reset mob cooldowns. Failsafe entries: " + failsafeEntries + ", mob partner cooldown sets: " + mobsReset + "."; + source.sendFeedback(() -> Text.literal(feedback), true); + return total; + } + + private static int cleanMessCommand(ServerCommandSource source, ServerPlayerEntity target) { + NonMessSystem.clearMess(target); + source.sendFeedback(() -> Text.literal(target.getName().getString() + " mess cleared."), true); + return 1; + } + + private static int setMessCommand(ServerCommandSource source, ServerPlayerEntity target, int v, int a, int m) { + if (!NeedsOfNature.isMessSystemEnabled()) { + source.sendError(Text.literal("污渍系统已禁用。")); + return 0; + } else if (target instanceof MessHolder holder) { + holder.setVMess(NonMessSystem.clampMess(v)); + holder.setAMess(NonMessSystem.clampMess(a)); + holder.setMMess(NonMessSystem.clampMess(m)); + NonMessSystem.broadcastMessState(target); + maybeGrantMessedUp(target, holder); + sendMessSetFeedback(source, target, holder); + return 1; + } else { + source.sendError(Text.literal("目标没有污渍数据。")); + return 0; + } + } + + private static int setMessSlotCommand(ServerCommandSource source, ServerPlayerEntity target, String slot, int value) { + if (!NeedsOfNature.isMessSystemEnabled()) { + source.sendError(Text.literal("污渍系统已禁用。")); + return 0; + } + + if (target instanceof MessHolder holder) { + int clamped = NonMessSystem.clampMess(value); + switch (slot) { + case "v": + holder.setVMess(clamped); + break; + case "a": + holder.setAMess(clamped); + break; + case "m": + holder.setMMess(clamped); + break; + default: + source.sendError(Text.literal("未知污渍槽位 '" + slot + "'. Use v, a, or m.")); + return 0; + } + + NonMessSystem.broadcastMessState(target); + maybeGrantMessedUp(target, holder); + sendMessSetFeedback(source, target, holder); + return 1; + } else { + source.sendError(Text.literal("目标没有污渍数据。")); + return 0; + } + } + + private static void sendMessSetFeedback(ServerCommandSource source, ServerPlayerEntity target, MessHolder holder) { + source.sendFeedback( + () -> Text.literal( + target.getName().getString() + + " mess set to V=" + + NonMessSystem.clampMess(holder.getVMess()) + + ", A=" + + NonMessSystem.clampMess(holder.getAMess()) + + ", M=" + + NonMessSystem.clampMess(holder.getMMess()) + + "." + ), + true + ); + } + + private static void maybeGrantMessedUp(ServerPlayerEntity target, MessHolder holder) { + if (NonMessSystem.clampMess(holder.getVMess()) >= 10 + && NonMessSystem.clampMess(holder.getAMess()) >= 10 + && NonMessSystem.clampMess(holder.getMMess()) >= 10) { + NonAdvancementHooks.grantMessedUp(target); + } + } + + private static int getGenderCommand(ServerCommandSource source, ServerPlayerEntity target) { + source.sendFeedback(() -> Text.literal(target.getName().getString() + " gender: " + genderName(currentGenderMask(target))), false); + return 1; + } + + private static int setGenderCommand(ServerCommandSource source, ServerPlayerEntity target, String genderRaw) { + int mask = parseGenderMask(genderRaw); + if (mask == 0) { + source.sendError(Text.literal("未知性别 '" + genderRaw + "'. Use male, female, or both.")); + return 0; + } else { + int applied = NonGenderSystem.setPlayerGenderFromCommand(target, mask); + source.sendFeedback(() -> Text.literal(target.getName().getString() + " gender set to " + genderName(applied) + "."), true); + return 1; + } + } + + private static int resetGenderCommand(ServerCommandSource source, ServerPlayerEntity target) { + NonGenderSystem.resetPlayerGenderSelection(target); + source.sendFeedback(() -> Text.literal(target.getName().getString() + " gender selection reset."), true); + return 1; + } + + private static int currentGenderMask(ServerPlayerEntity target) { + return target instanceof GenderHolder holder ? holder.getGenderMask() & 3 : 0; + } + + private static int parseGenderMask(String raw) { + if (raw == null) { + return 0; + } + + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "m", "male" -> 1; + case "f", "female" -> 2; + case "b", "both", "male+female", "female+male" -> 3; + default -> 0; + }; + } + + private static String genderName(int mask) { + return switch (mask & 3) { + case 1 -> "male"; + case 2 -> "female"; + case 3 -> "male+female"; + default -> "none"; + }; + } + + private static int setDestroyedSkinStageCommand(ServerCommandSource source, ServerPlayerEntity target, int stage) { + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + source.sendError(Text.literal("撕裂皮肤系统已禁用。")); + return 0; + } + + int clamped = Math.max(0, Math.min(4, stage)); + if (target instanceof DestroyedSkinHolder holder) { + holder.setDestroyedSkinDamage(NonDestroyedSkinSystem.destroyedSkinDamageForStage(clamped)); + } + + NonDestroyedSkinSystem.setDestroyedSkinStage(target, clamped); + source.sendFeedback( + () -> Text.literal( + target.getName().getString() + + " ripped skin stage set to " + + clamped + + " (damage " + + NonDestroyedSkinSystem.destroyedSkinDamageForStage(clamped) + + "/10)." + ), + true + ); + return clamped; + } + + private static int resetDestroyedSkinCommand(ServerCommandSource source, ServerPlayerEntity target) { + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + source.sendError(Text.literal("撕裂皮肤系统已禁用。")); + return 0; + } else { + NonDestroyedSkinSystem.resetDestroyedSkinDamage(target); + source.sendFeedback(() -> Text.literal(target.getName().getString() + " ripped skin damage reset."), true); + return 1; + } + } + + private static int sendPregnantEntity(ServerCommandSource source, ServerPlayerEntity target) { + Identifier pregnantEntity = NeedsOfNature.getPregnantEntityTypeId(target); + if (pregnantEntity == null) { + source.sendFeedback(() -> Text.literal(target.getName().getString() + " is not pregnant."), false); + return 0; + } else { + source.sendFeedback(() -> Text.literal(target.getName().getString() + " pregnant entity: " + pregnantEntity), false); + return 1; + } + } + + private static int setPregnancyCycleDayCommand(ServerCommandSource source, ServerPlayerEntity target, int day) { + if (!NonPregnancyConceptionSystem.setCycleDay(target, day)) { + source.sendError(Text.literal("目标没有孕期周期数据。")); + return 0; + } else { + source.sendFeedback(() -> Text.literal(target.getName().getString() + " pregnancy cycle set to day " + day + "/30."), true); + return 1; + } + } + + private static int setPregnancyCommand(ServerCommandSource source, ServerPlayerEntity target, String entityIdRaw, int minutes, int offspringCountOverride) { + if (NonGenderSystem.isOnlyMale(target)) { + source.sendError(Text.literal("无法为纯男性玩家分娩。")); + return 0; + } + + Identifier entityId = Identifier.tryParse(entityIdRaw); + if (entityId != null && Registries.ENTITY_TYPE.containsId(entityId)) { + if (target.getEntityWorld() instanceof ServerWorld world) { + long var13 = NeedsOfNature.getGlobalTick(world); + long durationTicks = Math.max(1L, Math.min(5256000L, minutes)) * 1200L; + NeedsOfNature.beginPregnancy(target, entityId, var13 + durationTicks, null, offspringCountOverride); + int resolvedDisplaySpawnCount = ((PregnancyHolder)target).getPregnancyOffspringCount(); + if (resolvedDisplaySpawnCount <= 0) { + resolvedDisplaySpawnCount = offspringCountOverride > 0 ? Math.max(1, Math.min(16, offspringCountOverride)) : 1; + } + + int displaySpawnCount = resolvedDisplaySpawnCount; + source.sendFeedback( + () -> Text.literal( + "Set pregnancy for " + + target.getName().getString() + + " to " + + entityId + + " (" + + minutes + + " minutes, " + + displaySpawnCount + + " spawn" + + (displaySpawnCount == 1 ? "" : "s") + + ")." + ), + true + ); + return 1; + } else { + source.sendError(Text.literal("目标不在服务器世界中。")); + return 0; + } + } else { + source.sendError(Text.literal("未知实体 ID: " + entityIdRaw)); + return 0; + } + } + + private static int clearPregnancyCommand(ServerCommandSource source, ServerPlayerEntity target) { + NeedsOfNature.clearPregnancyState(target, true); + source.sendFeedback(() -> Text.literal("已清除 " + target.getName().getString() + "."), true); + return 1; + } + + private static int completePregnancyCommand(ServerCommandSource source, ServerPlayerEntity target) { + Identifier pregnantEntity = NeedsOfNature.getPregnantEntityTypeId(target); + if (pregnantEntity == null) { + source.sendError(Text.literal(target.getName().getString() + " is not pregnant.")); + return 0; + } else if (target.getEntityWorld() instanceof ServerWorld world) { + NeedsOfNature.markPregnancyPending(target, NeedsOfNature.getGlobalTick(world)); + source.sendFeedback(() -> Text.literal("已标记 " + target.getName().getString() + "."), true); + return 1; + } else { + source.sendError(Text.literal("目标不在服务器世界中。")); + return 0; + } + } +} diff --git a/src/main/java/com/nonid/NonConfig.java b/src/main/java/com/nonid/NonConfig.java new file mode 100644 index 0000000..c61ae5a --- /dev/null +++ b/src/main/java/com/nonid/NonConfig.java @@ -0,0 +1,2783 @@ +package com.nonid; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.nonid.client.NonLiquidColors; +import com.nonid.data.NonEntityProfiles; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.util.Identifier; + +public final class NonConfig { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("needsofnature.json"); + private static final int PREGNANCY_DURATION_MINUTES_MIN = 1; + private static final int PREGNANCY_DURATION_MINUTES_MAX = 5256000; + public static final int DEFAULT_GENDER_MALE_CHANCE_PERCENT = 48; + public static final int DEFAULT_GENDER_FEMALE_CHANCE_PERCENT = 47; + public static final int DEFAULT_GENDER_BOTH_CHANCE_PERCENT = 5; + public static final int DEFAULT_LIQUID_TANK_CAPACITY_ML = 200; + public static final int LIQUID_TANK_CAPACITY_MIN_ML = 1; + public static final int LIQUID_TANK_CAPACITY_MAX_ML = 10000; + public static final int DEFAULT_ATTACK_OUTCOME_FAILSAFE_THRESHOLD = 3; + public static final int DEFAULT_ACTION_SOUND_VOLUME_PERCENT = 100; + public static final double DEFAULT_PEAK_XP_PER_ML = 0.06666666666666667; + public static final int DEFAULT_NORMAL_DAMAGE_DESTROYED_SKIN_CHANCE_PERCENT = 10; + public static final int DEFAULT_LAST_DEFEATED_ENERGY_THRESHOLD = 70; + public static final int DEFAULT_LAST_DEFEATED_SEARCH_RADIUS = 6; + public static final int DEFAULT_LAST_DEFEATED_COOLDOWN_SECONDS = 120; + public static final int DEFAULT_ATTACK_ESCAPE_INVULNERABILITY_SECONDS = 3; + public static final int DEFAULT_ATTACK_ESCAPE_ANIMATION_PROTECTION_SECONDS = 3; + public static final int ATTACK_ESCAPE_PROTECTION_SECONDS_MAX = 10; + public static final int DEFAULT_ATTACK_ESCAPE_DAMAGE_DIFFICULTY_PERCENT = 100; + public static final int ATTACK_ESCAPE_DAMAGE_DIFFICULTY_PERCENT_MAX = 200; + public static final int DEFAULT_PVP_FULL_HEALTH_ESCAPE_BONUS_PERCENT = 30; + public static final int DEFAULT_PVP_CRITICAL_HEALTH_MASH_STRENGTH_PERCENT = 25; + public static final int DEFAULT_PVP_MAX_HEAT_ESCAPE_STRENGTH_PERCENT = 70; + public static final int DEFAULT_PVP_ZERO_HEAT_ATTACK_STRENGTH_PERCENT = 70; + public static final int PVP_MASH_PERCENT_MAX = 200; + public static final int DEFAULT_POST_ESCAPE_GATHER_MAX_MOBS = 8; + public static final int DEFAULT_ALLOWED_PLAYER_INTERACTION_PROFILES_MASK = 15; + public static final int DEFAULT_PLAYER_REQUEST_MIN_ENERGY = 70; + public static final boolean DEFAULT_BLOCK_ANIMATIONS_WHILE_RIDING_LIVING_ENTITIES = true; + public static final boolean DEFAULT_BLOCK_ANIMATIONS_WHILE_RIDING_VEHICLES = false; + public static final int PREGNANCY_OFFSPRING_COUNT_MIN = 1; + public static final int PREGNANCY_OFFSPRING_COUNT_MAX = 16; + public static final float ENTITY_ENERGY_GAIN_MULTIPLIER_MIN = 0.1F; + public static final float ENTITY_ENERGY_GAIN_MULTIPLIER_MAX = 10.0F; + public static final int PREGNANCY_CYCLE_DAYS = 30; + public static final boolean DEFAULT_LIQUID_BASED_PREGNANCY = false; + public static final int DEFAULT_PREGNANCY_RISKY_START_DAY = 13; + public static final int DEFAULT_PREGNANCY_OVULATION_END_DAY = 17; + public static final int DEFAULT_PREGNANCY_SAFE_DAY_CHANCE_PERCENT = 10; + public static final int DEFAULT_PREGNANCY_RISKY_CHANCE_PERCENT = 300; + public static final int DEFAULT_PREGNANCY_OVULATION_CHANCE_PERCENT = 500; + public static final int PREGNANCY_CYCLE_CHANCE_PERCENT_MAX = 1000; + public static final float DEFAULT_PREGNANCY_EGG_START_SIZE = 0.5F; + public static final float DEFAULT_PREGNANCY_EGG_END_SIZE = 1.0F; + public static final float DEFAULT_PREGNANCY_EGG_HEALTH = 2.0F; + public static final float PREGNANCY_EGG_SIZE_MIN = 0.05F; + public static final float PREGNANCY_EGG_SIZE_MAX = 4.0F; + public static final float PREGNANCY_EGG_HEALTH_MIN = 0.5F; + public static final float PREGNANCY_EGG_HEALTH_MAX = 100.0F; + public static final String PREGNANCY_BIRTH_MODE_EGG = "egg"; + public static final String PREGNANCY_BIRTH_MODE_DIRECT = "direct"; + public static final String DEFAULT_DESTROYED_SKIN_TINT_HEX = ""; + public static final String DEFAULT_INTIFACE_SERVER_URL = "ws://127.0.0.1:12345"; + public static final int DEFAULT_INTIFACE_REACTIVE_IMPACT_STRENGTH_PERCENT = 60; + public static final int DEFAULT_INTIFACE_REACTIVE_IMPACT_DURATION_MS = 170; + public static final int DEFAULT_INTIFACE_PEAK_REACTIVE_IMPACT_STRENGTH_PERCENT = 20; + public static final int DEFAULT_INTIFACE_PEAK_REACTIVE_IMPACT_DURATION_MS = 500; + public static final int DEFAULT_INTIFACE_COOLDOWN_MS = 80; + public static final int DEFAULT_INTIFACE_LATENCY_COMPENSATION_MS = 0; + public static final int DEFAULT_INTIFACE_ANIMATION_BASELINE_STRENGTH_PERCENT = 1; + public static final int DEFAULT_INTIFACE_OSCILLATOR_REGULAR_SPEED_PERCENT = 100; + public static final int DEFAULT_INTIFACE_OSCILLATOR_PEAK_SPEED_PERCENT = 50; + public static final int DEFAULT_INTIFACE_STROKER_MIN_DISTANCE_PERCENT = 0; + public static final int DEFAULT_INTIFACE_STROKER_MAX_DISTANCE_PERCENT = 100; + public static final int DEFAULT_INTIFACE_STROKER_REGULAR_MOVE_DURATION_MS = 500; + public static final int DEFAULT_INTIFACE_STROKER_PEAK_MOVE_DURATION_MS = 300; + public static final int DEFAULT_INTIFACE_ENERGIZED_ONE_BASE_PERCENT = 1; + public static final int DEFAULT_INTIFACE_ENERGIZED_ONE_PULSE_PERCENT = 5; + public static final int DEFAULT_INTIFACE_ENERGIZED_TWO_BASE_PERCENT = 5; + public static final int DEFAULT_INTIFACE_ENERGIZED_TWO_PULSE_PERCENT = 10; + public static final int DEFAULT_INTIFACE_ENERGIZED_THREE_BASE_PERCENT = 10; + public static final int DEFAULT_INTIFACE_ENERGIZED_THREE_PULSE_PERCENT = 20; + public static final float FEMALE_GENDER_BREAST_SIZE_MAX = 1.6F; + public static final float FEMALE_GENDER_BREAST_HEIGHT_MIN = -2.0F; + private int initialEnergyMax = 90; + private double energyGainRate = 1.0; + private double nearAnimationEnergyGainMult = 3.0; + private double playerEnergyAuraMultLow = 2.0; + private double playerEnergyAuraMultMid = 5.0; + private double playerEnergyAuraMultHigh = 10.0; + private double destroyedSkinAuraMultStage1 = 2.0; + private double destroyedSkinAuraMultStage2 = 5.0; + private double destroyedSkinAuraMultStage3 = 20.0; + private double destroyedSkinAuraMultStage4 = 50.0; + private int normalDamageDestroyedSkinChancePercent = 10; + private boolean lastDefeatedEnabled = true; + private int lastDefeatedEnergyThreshold = 70; + private int lastDefeatedSearchRadius = 6; + private int lastDefeatedCooldownSeconds = 120; + private int playerEnergyAuraRadiusMin = 6; + private int playerEnergyAuraRadiusMax = 20; + private int playerEnergyAuraPulseTicks = 20; + private String debugChatMode = null; + private boolean forceVanillaEntityTextures = false; + private int animationBlockSearchRadius = 3; + private boolean autoSwitchThirdPersonOnAnimationStart = true; + private int scanBudgetPerTick = 32; + private int loopProgressSeconds = 15; + private int peakLoopProgressSeconds = 7; + private int genderMaleChancePercent = 48; + private int genderFemaleChancePercent = 47; + private int genderBothChancePercent = 5; + private int maleMaleChancePercent = 20; + private int femaleFemaleChancePercent = 20; + private int entityEntityChancePercent = 20; + private int multiActorJoinChancePercent = 75; + private String playerGender = NonConfig.PlayerGenderSelection.FEMALE.id(); + private boolean allowPlayerGenderChangeAnytime = true; + private boolean requirePlayerGenderSelectionOnJoin = false; + private int allowedStartingGenderMask = 7; + private int allowedPlayerInteractionProfilesMask = 15; + private int playerRequestMinEnergy = 70; + private boolean pregnancyEnabled = true; + private boolean liquidTankEnabled = true; + private boolean messSystemEnabled = true; + private boolean destroyedSkinSystemEnabled = true; + private boolean allowCraftingTableSkinRepair = false; + private boolean keepMessAfterDeath = false; + private boolean keepRippedSkinAfterDeath = false; + private boolean keepLiquidTankAfterDeath = false; + private boolean keepPregnancyAfterDeath = false; + private boolean armorShedEffectEnabled = true; + private boolean syncFemaleGenderModWithNonGender = true; + private boolean destroyedSkinFemaleGenderOverridesEnabled = true; + private NonConfig.FemaleGenderDestroyedProfile femaleGenderDestroyedLow = NonConfig.FemaleGenderDestroyedProfile.lowDefault(); + private NonConfig.FemaleGenderDestroyedProfile femaleGenderDestroyedHigh = NonConfig.FemaleGenderDestroyedProfile.highDefault(); + private int actionSoundVolumePercent = 100; + private boolean intifaceEnabled = false; + private boolean intifaceAutoConnectOnWorldJoin = true; + private boolean intifaceStopOnDisconnect = true; + private String intifaceServerUrl = "ws://127.0.0.1:12345"; + private int intifaceReactiveImpactStrengthPercent = 60; + private int intifaceReactiveImpactDurationMs = 170; + private int intifacePeakReactiveImpactStrengthPercent = 20; + private int intifacePeakReactiveImpactDurationMs = 500; + private int intifaceCooldownMs = 80; + private int intifaceLatencyCompensationMs = 0; + private int intifaceAnimationBaselineStrengthPercent = 1; + private int intifaceOscillatorRegularSpeedPercent = 100; + private int intifaceOscillatorPeakSpeedPercent = 50; + private int intifaceStrokerMinDistancePercent = 0; + private int intifaceStrokerMaxDistancePercent = 100; + private int intifaceStrokerRegularMoveDurationMs = 500; + private int intifaceStrokerPeakMoveDurationMs = 300; + private int intifaceEnergizedOneBasePercent = 1; + private int intifaceEnergizedOnePulsePercent = 5; + private int intifaceEnergizedTwoBasePercent = 5; + private int intifaceEnergizedTwoPulsePercent = 10; + private int intifaceEnergizedThreeBasePercent = 10; + private int intifaceEnergizedThreePulsePercent = 20; + private int liquidTankCapacityMl = 200; + private boolean vanillaOverridesEnabled = true; + private boolean useAnimationBreeding = true; + private boolean requireMaleFemaleForBreeding = true; + private boolean femaleCowOnlyMilking = true; + private boolean attackCreativePlayers = false; + private boolean convertMaleOnlyVInjectionsToA = true; + private int attackEscapeHits = 12; + private double attackDecayPerSecond = 0.2; + private int attackEscapeDamageDifficultyPercent = 100; + private int pvpFullHealthEscapeBonusPercent = 30; + private int pvpCriticalHealthMashStrengthPercent = 25; + private int pvpMaxHeatEscapeStrengthPercent = 70; + private int pvpZeroHeatAttackStrengthPercent = 70; + private Integer attackEscapeInvulnerabilityTicks = null; + private int attackEscapeInvulnerabilitySeconds = 3; + private int attackEscapeAnimationProtectionSeconds = 3; + private int postEscapeGatherMaxMobs = 8; + private boolean blockAnimationsWhileRidingLivingEntities = true; + private boolean blockAnimationsWhileRidingVehicles = false; + private int attackOutcomeFailsafeThreshold = 3; + private int pregnancyChancePercent = 5; + private int pregnancyDurationMinutes = 3; + private boolean liquidBasedPregnancy = false; + private int pregnancyRiskyStartDay = 13; + private int pregnancyOvulationEndDay = 17; + private int pregnancySafeDayChancePercent = 10; + private Integer pregnancyRiskyChancePercent; + private int pregnancyOvulationChancePercent = 500; + private Map pregnancyChanceByEntity = new LinkedHashMap<>(); + private Map pregnancyOffspringCountByEntity = new LinkedHashMap<>(); + private int pregnancyEggDefaultHatchSeconds = 60; + private Map pregnancyEggEnabledByEntity = new LinkedHashMap<>(); + private Map pregnancyEggHatchSecondsByEntity = new LinkedHashMap<>(); + private boolean pregnancyAutoTameMobs = true; + private boolean pregnancyFriendlyMobsIgnorePlayers = false; + private Map entityProfilesByEntity = new LinkedHashMap<>(); + private double liquidDecayPerSecond = 1.0; + private int liquidPuddleDespawnSeconds = 40; + private double peakXpPerMl = 0.06666666666666667; + private float filledStageOneSpeedMult = 0.8F; + private float filledStageTwoSpeedMult = 0.7F; + private float filledStageThreeSpeedMult = 0.6F; + private float filledStageOneJumpMult = 0.9F; + private float filledStageTwoJumpMult = 0.75F; + private float filledStageThreeJumpMult = 0.6F; + private int uiEnergyOffsetX = 0; + private int uiEnergyOffsetY = 0; + private int uiEnergyHeartOffsetX = 0; + private int uiEnergyHeartOffsetY = 0; + private int uiAttackOffsetX = 0; + private int uiAttackOffsetY = 0; + private int uiPromptOffsetX = 0; + private int uiPromptOffsetY = 0; + private int uiLiquidOffsetX = 0; + private int uiLiquidOffsetY = 0; + private boolean uiEnergyVisible = true; + private boolean uiEnergyHeartVisible = true; + private boolean uiAttackVisible = true; + private boolean uiPromptVisible = true; + private boolean uiLiquidVisible = true; + private boolean debugSpinMode = false; + private String destroyedSkinTintHex = ""; + private Map genderSpawnByEntity = new LinkedHashMap<>(); + private Map liquidGainByEntity = new LinkedHashMap<>(); + private Map liquidColorByEntity = new LinkedHashMap<>(); + private List externalNoNPackLoadOrder = new ArrayList<>(); + private List disabledAnimationPacks = new ArrayList<>(); + private List disabledAnimations = new ArrayList<>(); + private List acknowledgedStartupWarnings = new ArrayList<>(); + + public int getInitialEnergyMax() { + return Math.max(0, Math.min(200, this.initialEnergyMax)); + } + + public void setInitialEnergyMax(int value) { + this.initialEnergyMax = Math.max(0, Math.min(200, value)); + } + + public double getEnergyGainRate() { + return Math.max(0.1, Math.min(10.0, this.energyGainRate)); + } + + public void setEnergyGainRate(double value) { + this.energyGainRate = Math.max(0.1, Math.min(10.0, value)); + } + + public int getActionSoundVolumePercent() { + return Math.max(0, Math.min(200, this.actionSoundVolumePercent)); + } + + public float getActionSoundVolumeMultiplier() { + return this.getActionSoundVolumePercent() / 100.0F; + } + + public void setActionSoundVolumePercent(int value) { + this.actionSoundVolumePercent = Math.max(0, Math.min(200, value)); + } + + public boolean isIntifaceEnabled() { + return this.intifaceEnabled; + } + + public void setIntifaceEnabled(boolean value) { + this.intifaceEnabled = value; + } + + public boolean isIntifaceAutoConnectOnWorldJoin() { + return this.intifaceAutoConnectOnWorldJoin; + } + + public void setIntifaceAutoConnectOnWorldJoin(boolean value) { + this.intifaceAutoConnectOnWorldJoin = value; + } + + public boolean isIntifaceStopOnDisconnect() { + return this.intifaceStopOnDisconnect; + } + + public void setIntifaceStopOnDisconnect(boolean value) { + this.intifaceStopOnDisconnect = value; + } + + public String getIntifaceServerUrl() { + return sanitizeIntifaceServerUrl(this.intifaceServerUrl); + } + + public void setIntifaceServerUrl(String value) { + this.intifaceServerUrl = sanitizeIntifaceServerUrl(value); + } + + public int getIntifaceReactiveImpactStrengthPercent() { + return clampPercent(this.intifaceReactiveImpactStrengthPercent); + } + + public void setIntifaceReactiveImpactStrengthPercent(int value) { + this.intifaceReactiveImpactStrengthPercent = clampPercent(value); + } + + public int getIntifaceReactiveImpactDurationMs() { + return clampInt(valueOrDefault(this.intifaceReactiveImpactDurationMs, 170), 20, 5000); + } + + public void setIntifaceReactiveImpactDurationMs(int value) { + this.intifaceReactiveImpactDurationMs = clampInt(value, 20, 5000); + } + + public int getIntifacePeakReactiveImpactStrengthPercent() { + return clampPercent(this.intifacePeakReactiveImpactStrengthPercent); + } + + public void setIntifacePeakReactiveImpactStrengthPercent(int value) { + this.intifacePeakReactiveImpactStrengthPercent = clampPercent(value); + } + + public int getIntifacePeakReactiveImpactDurationMs() { + return clampInt(valueOrDefault(this.intifacePeakReactiveImpactDurationMs, 500), 20, 5000); + } + + public void setIntifacePeakReactiveImpactDurationMs(int value) { + this.intifacePeakReactiveImpactDurationMs = clampInt(value, 20, 5000); + } + + public int getIntifaceCooldownMs() { + return clampInt(this.intifaceCooldownMs, 0, 5000); + } + + public void setIntifaceCooldownMs(int value) { + this.intifaceCooldownMs = clampInt(value, 0, 5000); + } + + public int getIntifaceLatencyCompensationMs() { + return clampInt(this.intifaceLatencyCompensationMs, 0, 1000); + } + + public void setIntifaceLatencyCompensationMs(int value) { + this.intifaceLatencyCompensationMs = clampInt(value, 0, 1000); + } + + public int getIntifaceAnimationBaselineStrengthPercent() { + return clampPercent(this.intifaceAnimationBaselineStrengthPercent); + } + + public void setIntifaceAnimationBaselineStrengthPercent(int value) { + this.intifaceAnimationBaselineStrengthPercent = clampPercent(value); + } + + public int getIntifaceOscillatorRegularSpeedPercent() { + return clampPercent(this.intifaceOscillatorRegularSpeedPercent); + } + + public void setIntifaceOscillatorRegularSpeedPercent(int value) { + this.intifaceOscillatorRegularSpeedPercent = clampPercent(value); + } + + public int getIntifaceOscillatorPeakSpeedPercent() { + return clampPercent(this.intifaceOscillatorPeakSpeedPercent); + } + + public void setIntifaceOscillatorPeakSpeedPercent(int value) { + this.intifaceOscillatorPeakSpeedPercent = clampPercent(value); + } + + public int getIntifaceStrokerMinDistancePercent() { + return clampPercent(this.intifaceStrokerMinDistancePercent); + } + + public void setIntifaceStrokerMinDistancePercent(int value) { + this.intifaceStrokerMinDistancePercent = Math.min(clampPercent(value), this.getIntifaceStrokerMaxDistancePercent()); + } + + public int getIntifaceStrokerMaxDistancePercent() { + return clampPercent(this.intifaceStrokerMaxDistancePercent); + } + + public void setIntifaceStrokerMaxDistancePercent(int value) { + this.intifaceStrokerMaxDistancePercent = Math.max(clampPercent(value), this.getIntifaceStrokerMinDistancePercent()); + } + + public void setIntifaceStrokerDistancesPercent(int minValue, int maxValue) { + int min = clampPercent(Math.min(minValue, maxValue)); + int max = clampPercent(Math.max(minValue, maxValue)); + this.intifaceStrokerMinDistancePercent = min; + this.intifaceStrokerMaxDistancePercent = max; + } + + public int getIntifaceStrokerRegularMoveDurationMs() { + return clampInt(this.intifaceStrokerRegularMoveDurationMs, 0, 10000); + } + + public void setIntifaceStrokerRegularMoveDurationMs(int value) { + this.intifaceStrokerRegularMoveDurationMs = clampInt(value, 0, 10000); + } + + public int getIntifaceStrokerPeakMoveDurationMs() { + return clampInt(this.intifaceStrokerPeakMoveDurationMs, 0, 10000); + } + + public void setIntifaceStrokerPeakMoveDurationMs(int value) { + this.intifaceStrokerPeakMoveDurationMs = clampInt(value, 0, 10000); + } + + public int getIntifaceEnergizedBasePercent(int level) { + return switch (Math.max(1, Math.min(3, level))) { + case 1 -> clampPercent(this.intifaceEnergizedOneBasePercent); + case 2 -> clampPercent(this.intifaceEnergizedTwoBasePercent); + default -> clampPercent(this.intifaceEnergizedThreeBasePercent); + }; + } + + public void setIntifaceEnergizedBasePercent(int level, int value) { + int clamped = clampPercent(value); + switch (Math.max(1, Math.min(3, level))) { + case 1: + this.intifaceEnergizedOneBasePercent = clamped; + break; + case 2: + this.intifaceEnergizedTwoBasePercent = clamped; + break; + default: + this.intifaceEnergizedThreeBasePercent = clamped; + } + } + + public int getIntifaceEnergizedPulsePercent(int level) { + return switch (Math.max(1, Math.min(3, level))) { + case 1 -> clampPercent(this.intifaceEnergizedOnePulsePercent); + case 2 -> clampPercent(this.intifaceEnergizedTwoPulsePercent); + default -> clampPercent(this.intifaceEnergizedThreePulsePercent); + }; + } + + public void setIntifaceEnergizedPulsePercent(int level, int value) { + int clamped = clampPercent(value); + switch (Math.max(1, Math.min(3, level))) { + case 1: + this.intifaceEnergizedOnePulsePercent = clamped; + break; + case 2: + this.intifaceEnergizedTwoPulsePercent = clamped; + break; + default: + this.intifaceEnergizedThreePulsePercent = clamped; + } + } + + public double getNearAnimationEnergyGainMult() { + return Math.max(1.0, Math.min(10.0, this.nearAnimationEnergyGainMult)); + } + + public void setNearAnimationEnergyGainMult(double value) { + this.nearAnimationEnergyGainMult = Math.max(1.0, Math.min(10.0, value)); + } + + public double getPlayerEnergyAuraMultLow() { + return Math.max(1.0, Math.min(20.0, this.playerEnergyAuraMultLow)); + } + + public void setPlayerEnergyAuraMultLow(double value) { + this.playerEnergyAuraMultLow = Math.max(1.0, Math.min(20.0, value)); + } + + public double getPlayerEnergyAuraMultMid() { + return Math.max(1.0, Math.min(20.0, this.playerEnergyAuraMultMid)); + } + + public void setPlayerEnergyAuraMultMid(double value) { + this.playerEnergyAuraMultMid = Math.max(1.0, Math.min(20.0, value)); + } + + public double getPlayerEnergyAuraMultHigh() { + return Math.max(1.0, Math.min(20.0, this.playerEnergyAuraMultHigh)); + } + + public void setPlayerEnergyAuraMultHigh(double value) { + this.playerEnergyAuraMultHigh = Math.max(1.0, Math.min(20.0, value)); + } + + public double getDestroyedSkinAuraMultStage1() { + return this.clampDestroyedSkinAuraMult(this.destroyedSkinAuraMultStage1); + } + + public void setDestroyedSkinAuraMultStage1(double value) { + this.destroyedSkinAuraMultStage1 = this.clampDestroyedSkinAuraMult(value); + } + + public double getDestroyedSkinAuraMultStage2() { + return this.clampDestroyedSkinAuraMult(this.destroyedSkinAuraMultStage2); + } + + public void setDestroyedSkinAuraMultStage2(double value) { + this.destroyedSkinAuraMultStage2 = this.clampDestroyedSkinAuraMult(value); + } + + public double getDestroyedSkinAuraMultStage3() { + return this.clampDestroyedSkinAuraMult(this.destroyedSkinAuraMultStage3); + } + + public void setDestroyedSkinAuraMultStage3(double value) { + this.destroyedSkinAuraMultStage3 = this.clampDestroyedSkinAuraMult(value); + } + + public double getDestroyedSkinAuraMultStage4() { + return this.clampDestroyedSkinAuraMult(this.destroyedSkinAuraMultStage4); + } + + public void setDestroyedSkinAuraMultStage4(double value) { + this.destroyedSkinAuraMultStage4 = this.clampDestroyedSkinAuraMult(value); + } + + private double clampDestroyedSkinAuraMult(double value) { + return Math.max(1.0, Math.min(50.0, value)); + } + + public int getNormalDamageDestroyedSkinChancePercent() { + return clampPercent(this.normalDamageDestroyedSkinChancePercent); + } + + public void setNormalDamageDestroyedSkinChancePercent(int value) { + this.normalDamageDestroyedSkinChancePercent = clampPercent(value); + } + + public boolean isLastDefeatedEnabled() { + return this.lastDefeatedEnabled; + } + + public void setLastDefeatedEnabled(boolean value) { + this.lastDefeatedEnabled = value; + } + + public int getLastDefeatedEnergyThreshold() { + return Math.max(0, Math.min(200, this.lastDefeatedEnergyThreshold)); + } + + public void setLastDefeatedEnergyThreshold(int value) { + this.lastDefeatedEnergyThreshold = Math.max(0, Math.min(200, value)); + } + + public int getLastDefeatedSearchRadius() { + return Math.max(1, Math.min(32, this.lastDefeatedSearchRadius)); + } + + public void setLastDefeatedSearchRadius(int value) { + this.lastDefeatedSearchRadius = Math.max(1, Math.min(32, value)); + } + + public int getLastDefeatedCooldownSeconds() { + return Math.max(0, Math.min(3600, this.lastDefeatedCooldownSeconds)); + } + + public void setLastDefeatedCooldownSeconds(int value) { + this.lastDefeatedCooldownSeconds = Math.max(0, Math.min(3600, value)); + } + + public int getPlayerEnergyAuraRadiusMin() { + return Math.max(1, Math.min(64, this.playerEnergyAuraRadiusMin)); + } + + public void setPlayerEnergyAuraRadiusMin(int value) { + this.playerEnergyAuraRadiusMin = Math.max(1, Math.min(64, value)); + } + + public int getPlayerEnergyAuraRadiusMax() { + return Math.max(this.getPlayerEnergyAuraRadiusMin(), Math.min(64, this.playerEnergyAuraRadiusMax)); + } + + public void setPlayerEnergyAuraRadiusMax(int value) { + this.playerEnergyAuraRadiusMax = Math.max(1, Math.min(64, value)); + } + + public int getPlayerEnergyAuraPulseTicks() { + return Math.max(1, Math.min(1200, this.playerEnergyAuraPulseTicks)); + } + + public void setPlayerEnergyAuraPulseTicks(int value) { + this.playerEnergyAuraPulseTicks = Math.max(1, Math.min(1200, value)); + } + + public NonDebugChatMode debugChatMode() { + return NonDebugChatMode.fromId(this.debugChatMode, NonDebugChatMode.SETUP_ERRORS); + } + + public void setDebugChatMode(NonDebugChatMode mode) { + NonDebugChatMode resolved = mode == null ? NonDebugChatMode.SETUP_ERRORS : mode; + this.debugChatMode = resolved.id(); + } + + public boolean allowsDebugChat(NonDebugChatCategory category) { + return this.debugChatMode().allows(category); + } + + public boolean forceVanillaEntityTextures() { + return this.forceVanillaEntityTextures; + } + + public void setForceVanillaEntityTextures(boolean value) { + this.forceVanillaEntityTextures = value; + } + + public int blockSearchRadius() { + return Math.max(1, Math.min(16, this.animationBlockSearchRadius)); + } + + public void setBlockSearchRadius(int value) { + this.animationBlockSearchRadius = Math.max(1, Math.min(16, value)); + } + + public boolean autoSwitchThirdPersonOnAnimationStart() { + return this.autoSwitchThirdPersonOnAnimationStart; + } + + public void setAutoSwitchThirdPersonOnAnimationStart(boolean value) { + this.autoSwitchThirdPersonOnAnimationStart = value; + } + + public int getScanBudgetPerTick() { + return Math.max(1, Math.min(512, this.scanBudgetPerTick)); + } + + public void setScanBudgetPerTick(int value) { + this.scanBudgetPerTick = Math.max(1, Math.min(512, value)); + } + + public int getLoopProgressSeconds() { + return Math.max(5, Math.min(300, this.loopProgressSeconds)); + } + + public void setLoopProgressSeconds(int seconds) { + this.loopProgressSeconds = Math.max(5, Math.min(300, seconds)); + } + + public int getPeakLoopProgressSeconds() { + return Math.max(1, Math.min(300, this.peakLoopProgressSeconds)); + } + + public void setPeakLoopProgressSeconds(int seconds) { + this.peakLoopProgressSeconds = Math.max(1, Math.min(300, seconds)); + } + + public int getGenderMaleChancePercent() { + return clampPercent(this.genderMaleChancePercent); + } + + public void setGenderMaleChancePercent(int percent) { + this.genderMaleChancePercent = clampPercent(percent); + } + + public int getGenderFemaleChancePercent() { + return clampPercent(this.genderFemaleChancePercent); + } + + public void setGenderFemaleChancePercent(int percent) { + this.genderFemaleChancePercent = clampPercent(percent); + } + + public int getGenderBothChancePercent() { + return clampPercent(this.genderBothChancePercent); + } + + public void setGenderBothChancePercent(int percent) { + this.genderBothChancePercent = clampPercent(percent); + } + + public NonConfig.GenderSpawnChances getGlobalGenderSpawnChances() { + NonConfig.GenderSpawnChances chances = new NonConfig.GenderSpawnChances( + this.getGenderMaleChancePercent(), this.getGenderFemaleChancePercent(), this.getGenderBothChancePercent() + ); + return chances.isValid() ? chances : defaultGenderSpawnChances(); + } + + public void setGlobalGenderSpawnChances(NonConfig.GenderSpawnChances chances) { + NonConfig.GenderSpawnChances safe = validOrDefault(chances); + this.setGenderMaleChancePercent(safe.male()); + this.setGenderFemaleChancePercent(safe.female()); + this.setGenderBothChancePercent(safe.both()); + } + + public int getMaleMaleChancePercent() { + return Math.max(0, Math.min(100, this.maleMaleChancePercent)); + } + + public void setMaleMaleChancePercent(int percent) { + this.maleMaleChancePercent = Math.max(0, Math.min(100, percent)); + } + + public int getFemaleFemaleChancePercent() { + return Math.max(0, Math.min(100, this.femaleFemaleChancePercent)); + } + + public void setFemaleFemaleChancePercent(int percent) { + this.femaleFemaleChancePercent = Math.max(0, Math.min(100, percent)); + } + + public int getEntityEntityChancePercent() { + return Math.max(0, Math.min(100, this.entityEntityChancePercent)); + } + + public void setEntityEntityChancePercent(int percent) { + this.entityEntityChancePercent = Math.max(0, Math.min(100, percent)); + } + + public int getMultiActorJoinChancePercent() { + return Math.max(0, Math.min(100, this.multiActorJoinChancePercent)); + } + + public void setMultiActorJoinChancePercent(int percent) { + this.multiActorJoinChancePercent = Math.max(0, Math.min(100, percent)); + } + + public NonConfig.PlayerGenderSelection getPlayerGenderSelection() { + return NonConfig.PlayerGenderSelection.fromId(this.playerGender, NonConfig.PlayerGenderSelection.FEMALE); + } + + public void setPlayerGenderSelection(NonConfig.PlayerGenderSelection selection) { + NonConfig.PlayerGenderSelection resolved = selection == null ? NonConfig.PlayerGenderSelection.FEMALE : selection; + this.playerGender = resolved.id(); + } + + public int getPlayerGenderMask() { + return this.getPlayerGenderSelection().mask(); + } + + public boolean allowPlayerGenderChangeAnytime() { + return this.allowPlayerGenderChangeAnytime; + } + + public void setAllowPlayerGenderChangeAnytime(boolean value) { + this.allowPlayerGenderChangeAnytime = value; + } + + public boolean requirePlayerGenderSelectionOnJoin() { + return this.requirePlayerGenderSelectionOnJoin; + } + + public void setRequirePlayerGenderSelectionOnJoin(boolean value) { + this.requirePlayerGenderSelectionOnJoin = value; + } + + public int getAllowedStartingGenderMask() { + int mask = this.allowedStartingGenderMask & 7; + return mask == 0 ? 7 : mask; + } + + public void setAllowedStartingGenderMask(int mask) { + int normalized = mask & 7; + this.allowedStartingGenderMask = normalized == 0 ? 7 : normalized; + } + + public int getAllowedPlayerInteractionProfilesMask() { + int normalized = this.allowedPlayerInteractionProfilesMask & 15; + return normalized == 0 ? 15 : normalized; + } + + public void setAllowedPlayerInteractionProfilesMask(int mask) { + int normalized = mask & 15; + this.allowedPlayerInteractionProfilesMask = normalized == 0 ? 15 : normalized; + } + + public int getPlayerRequestMinEnergy() { + return Math.max(0, Math.min(200, this.playerRequestMinEnergy)); + } + + public void setPlayerRequestMinEnergy(int value) { + this.playerRequestMinEnergy = Math.max(0, Math.min(200, value)); + } + + public boolean isPregnancyEnabled() { + return this.pregnancyEnabled; + } + + public void setPregnancyEnabled(boolean value) { + this.pregnancyEnabled = value; + } + + public boolean isLiquidTankEnabled() { + return this.liquidTankEnabled; + } + + public void setLiquidTankEnabled(boolean value) { + this.liquidTankEnabled = value; + } + + public boolean isMessSystemEnabled() { + return this.messSystemEnabled; + } + + public void setMessSystemEnabled(boolean value) { + this.messSystemEnabled = value; + } + + public boolean isDestroyedSkinSystemEnabled() { + return this.destroyedSkinSystemEnabled; + } + + public void setDestroyedSkinSystemEnabled(boolean value) { + this.destroyedSkinSystemEnabled = value; + } + + public boolean isCraftingTableSkinRepairAllowed() { + return this.allowCraftingTableSkinRepair; + } + + public void setCraftingTableSkinRepairAllowed(boolean value) { + this.allowCraftingTableSkinRepair = value; + } + + public boolean keepMessAfterDeath() { + return this.keepMessAfterDeath; + } + + public void setKeepMessAfterDeath(boolean value) { + this.keepMessAfterDeath = value; + } + + public boolean keepRippedSkinAfterDeath() { + return this.keepRippedSkinAfterDeath; + } + + public void setKeepRippedSkinAfterDeath(boolean value) { + this.keepRippedSkinAfterDeath = value; + } + + public boolean keepLiquidTankAfterDeath() { + return this.keepLiquidTankAfterDeath; + } + + public void setKeepLiquidTankAfterDeath(boolean value) { + this.keepLiquidTankAfterDeath = value; + } + + public boolean keepPregnancyAfterDeath() { + return this.keepPregnancyAfterDeath; + } + + public void setKeepPregnancyAfterDeath(boolean value) { + this.keepPregnancyAfterDeath = value; + } + + public boolean isArmorShedEffectEnabled() { + return this.armorShedEffectEnabled; + } + + public void setArmorShedEffectEnabled(boolean value) { + this.armorShedEffectEnabled = value; + } + + public boolean isSyncFemaleGenderModWithNonGender() { + return this.syncFemaleGenderModWithNonGender; + } + + public void setSyncFemaleGenderModWithNonGender(boolean value) { + this.syncFemaleGenderModWithNonGender = value; + } + + public boolean isDestroyedSkinFemaleGenderOverridesEnabled() { + return this.destroyedSkinFemaleGenderOverridesEnabled; + } + + public void setDestroyedSkinFemaleGenderOverridesEnabled(boolean value) { + this.destroyedSkinFemaleGenderOverridesEnabled = value; + } + + public NonConfig.FemaleGenderDestroyedProfile getFemaleGenderDestroyedLow() { + if (this.femaleGenderDestroyedLow == null) { + this.femaleGenderDestroyedLow = NonConfig.FemaleGenderDestroyedProfile.lowDefault(); + } + + this.femaleGenderDestroyedLow = this.femaleGenderDestroyedLow.sanitized(); + return this.femaleGenderDestroyedLow; + } + + public void setFemaleGenderDestroyedLow(NonConfig.FemaleGenderDestroyedProfile value) { + this.femaleGenderDestroyedLow = value == null ? NonConfig.FemaleGenderDestroyedProfile.lowDefault() : value.sanitized(); + } + + public NonConfig.FemaleGenderDestroyedProfile getFemaleGenderDestroyedHigh() { + if (this.femaleGenderDestroyedHigh == null) { + this.femaleGenderDestroyedHigh = NonConfig.FemaleGenderDestroyedProfile.highDefault(); + } + + this.femaleGenderDestroyedHigh = this.femaleGenderDestroyedHigh.sanitized(); + return this.femaleGenderDestroyedHigh; + } + + public void setFemaleGenderDestroyedHigh(NonConfig.FemaleGenderDestroyedProfile value) { + this.femaleGenderDestroyedHigh = value == null ? NonConfig.FemaleGenderDestroyedProfile.highDefault() : value.sanitized(); + } + + public int getLiquidTankCapacityMl() { + return Math.max(1, Math.min(10000, this.liquidTankCapacityMl)); + } + + public void setLiquidTankCapacityMl(int value) { + this.liquidTankCapacityMl = Math.max(1, Math.min(10000, value)); + } + + public boolean isVanillaOverridesEnabled() { + return this.vanillaOverridesEnabled; + } + + public void setVanillaOverridesEnabled(boolean value) { + this.vanillaOverridesEnabled = value; + } + + public boolean useAnimationBreeding() { + return this.vanillaOverridesEnabled && this.useAnimationBreeding; + } + + public void setUseAnimationBreeding(boolean value) { + this.useAnimationBreeding = value; + } + + public boolean requireMaleFemaleForBreeding() { + return this.vanillaOverridesEnabled && this.requireMaleFemaleForBreeding; + } + + public void setRequireMaleFemaleForBreeding(boolean value) { + this.requireMaleFemaleForBreeding = value; + } + + public boolean femaleCowOnlyMilking() { + return this.vanillaOverridesEnabled && this.femaleCowOnlyMilking; + } + + public void setFemaleCowOnlyMilking(boolean value) { + this.femaleCowOnlyMilking = value; + } + + public boolean canAttackCreativePlayers() { + return this.attackCreativePlayers; + } + + public void setAttackCreativePlayers(boolean value) { + this.attackCreativePlayers = value; + } + + public boolean convertMaleOnlyVInjectionsToA() { + return this.convertMaleOnlyVInjectionsToA; + } + + public void setConvertMaleOnlyVInjectionsToA(boolean value) { + this.convertMaleOnlyVInjectionsToA = value; + } + + public int getAttackEscapeHits() { + return Math.max(1, Math.min(50, this.attackEscapeHits)); + } + + public void setAttackEscapeHits(int hits) { + this.attackEscapeHits = Math.max(1, Math.min(50, hits)); + } + + public double getAttackDecayPerSecond() { + return Math.max(0.0, Math.min(5.0, this.attackDecayPerSecond)); + } + + public void setAttackDecayPerSecond(double decay) { + this.attackDecayPerSecond = Math.max(0.0, Math.min(5.0, decay)); + } + + public int getAttackEscapeDamageDifficultyPercent() { + return clampAttackEscapeDamageDifficultyPercent(this.attackEscapeDamageDifficultyPercent); + } + + public void setAttackEscapeDamageDifficultyPercent(int percent) { + this.attackEscapeDamageDifficultyPercent = clampAttackEscapeDamageDifficultyPercent(percent); + } + + public int getPvpFullHealthEscapeBonusPercent() { + return clampPvpMashPercent(this.pvpFullHealthEscapeBonusPercent); + } + + public void setPvpFullHealthEscapeBonusPercent(int percent) { + this.pvpFullHealthEscapeBonusPercent = clampPvpMashPercent(percent); + } + + public int getPvpCriticalHealthMashStrengthPercent() { + return clampPvpMashPercent(this.pvpCriticalHealthMashStrengthPercent); + } + + public void setPvpCriticalHealthMashStrengthPercent(int percent) { + this.pvpCriticalHealthMashStrengthPercent = clampPvpMashPercent(percent); + } + + public int getPvpMaxHeatEscapeStrengthPercent() { + return clampPvpMashPercent(this.pvpMaxHeatEscapeStrengthPercent); + } + + public void setPvpMaxHeatEscapeStrengthPercent(int percent) { + this.pvpMaxHeatEscapeStrengthPercent = clampPvpMashPercent(percent); + } + + public int getPvpZeroHeatAttackStrengthPercent() { + return clampPvpMashPercent(this.pvpZeroHeatAttackStrengthPercent); + } + + public void setPvpZeroHeatAttackStrengthPercent(int percent) { + this.pvpZeroHeatAttackStrengthPercent = clampPvpMashPercent(percent); + } + + public int getAttackEscapeInvulnerabilitySeconds() { + return clampAttackEscapeProtectionSeconds(this.attackEscapeInvulnerabilitySeconds); + } + + public void setAttackEscapeInvulnerabilitySeconds(int seconds) { + this.attackEscapeInvulnerabilityTicks = null; + this.attackEscapeInvulnerabilitySeconds = clampAttackEscapeProtectionSeconds(seconds); + } + + public int getAttackEscapeInvulnerabilityTicks() { + return secondsToTicks(this.getAttackEscapeInvulnerabilitySeconds()); + } + + public int getAttackEscapeAnimationProtectionSeconds() { + return clampAttackEscapeProtectionSeconds(this.attackEscapeAnimationProtectionSeconds); + } + + public void setAttackEscapeAnimationProtectionSeconds(int seconds) { + this.attackEscapeAnimationProtectionSeconds = clampAttackEscapeProtectionSeconds(seconds); + } + + public int getAttackEscapeAnimationProtectionTicks() { + return secondsToTicks(this.getAttackEscapeAnimationProtectionSeconds()); + } + + private static int clampAttackEscapeProtectionSeconds(int seconds) { + return Math.max(0, Math.min(10, seconds)); + } + + private static int clampAttackEscapeDamageDifficultyPercent(int percent) { + return Math.max(0, Math.min(200, percent)); + } + + private static int clampPvpMashPercent(int percent) { + return Math.max(0, Math.min(200, percent)); + } + + private static int secondsToTicks(int seconds) { + return clampAttackEscapeProtectionSeconds(seconds) * 20; + } + + private static int ticksToSeconds(int ticks) { + int clampedTicks = Math.max(0, ticks); + return clampedTicks == 0 ? 0 : clampAttackEscapeProtectionSeconds((clampedTicks + 19) / 20); + } + + public int getPostEscapeGatherMaxMobs() { + return Math.max(0, Math.min(64, this.postEscapeGatherMaxMobs)); + } + + public void setPostEscapeGatherMaxMobs(int value) { + this.postEscapeGatherMaxMobs = Math.max(0, Math.min(64, value)); + } + + public boolean blockAnimationsWhileRidingLivingEntities() { + return this.blockAnimationsWhileRidingLivingEntities; + } + + public void setBlockAnimationsWhileRidingLivingEntities(boolean value) { + this.blockAnimationsWhileRidingLivingEntities = value; + } + + public boolean blockAnimationsWhileRidingVehicles() { + return this.blockAnimationsWhileRidingVehicles; + } + + public void setBlockAnimationsWhileRidingVehicles(boolean value) { + this.blockAnimationsWhileRidingVehicles = value; + } + + public int getAttackOutcomeFailsafeThreshold() { + return Math.max(1, Math.min(50, this.attackOutcomeFailsafeThreshold)); + } + + public void setAttackOutcomeFailsafeThreshold(int threshold) { + this.attackOutcomeFailsafeThreshold = Math.max(1, Math.min(50, threshold)); + } + + public int getPregnancyChancePercent() { + return !this.pregnancyEnabled ? 0 : Math.max(0, Math.min(100, this.pregnancyChancePercent)); + } + + public void setPregnancyChancePercent(int value) { + this.pregnancyChancePercent = Math.max(0, Math.min(100, value)); + } + + public int getPregnancyDurationMinutes() { + return Math.max(1, Math.min(5256000, this.pregnancyDurationMinutes)); + } + + public void setPregnancyDurationMinutes(int minutes) { + this.pregnancyDurationMinutes = Math.max(1, Math.min(5256000, minutes)); + } + + public long getPregnancyDurationTicks() { + return this.getPregnancyDurationMinutes() * 1200L; + } + + public boolean isLiquidBasedPregnancyEnabled() { + return this.liquidBasedPregnancy; + } + + public void setLiquidBasedPregnancyEnabled(boolean value) { + this.liquidBasedPregnancy = value; + } + + public int getPregnancyRiskyStartDay() { + return clampInt(this.pregnancyRiskyStartDay, 1, 28); + } + + public void setPregnancyRiskyStartDay(int day) { + this.pregnancyRiskyStartDay = clampInt(day, 1, 28); + if (this.pregnancyOvulationEndDay < this.pregnancyRiskyStartDay + 2) { + this.pregnancyOvulationEndDay = this.pregnancyRiskyStartDay + 2; + } + } + + public int getPregnancyOvulationEndDay() { + return Math.max(this.getPregnancyRiskyStartDay() + 2, clampInt(this.pregnancyOvulationEndDay, 1, 30)); + } + + public void setPregnancyOvulationEndDay(int day) { + this.pregnancyOvulationEndDay = Math.max(this.getPregnancyRiskyStartDay() + 2, clampInt(day, 1, 30)); + } + + public int getPregnancySafeDayChancePercent() { + return clampInt(this.pregnancySafeDayChancePercent, 0, 1000); + } + + public void setPregnancySafeDayChancePercent(int percent) { + this.pregnancySafeDayChancePercent = clampInt(percent, 0, 1000); + } + + public int getPregnancyRiskyChancePercent() { + return this.pregnancyRiskyChancePercent == null ? 300 : clampInt(this.pregnancyRiskyChancePercent, 0, 1000); + } + + public void setPregnancyRiskyChancePercent(int percent) { + this.pregnancyRiskyChancePercent = clampInt(percent, 0, 1000); + } + + public int getPregnancyOvulationChancePercent() { + return clampInt(this.pregnancyOvulationChancePercent, 0, 1000); + } + + public void setPregnancyOvulationChancePercent(int percent) { + this.pregnancyOvulationChancePercent = clampInt(percent, 0, 1000); + } + + public Map getPregnancyChanceByEntity() { + return this.pregnancyChanceByEntity == null ? Map.of() : Map.copyOf(this.pregnancyChanceByEntity); + } + + public void setPregnancyChanceByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + int value = entry.getValue() == null ? 0 : entry.getValue(); + value = Math.max(0, Math.min(100, value)); + cleaned.put(key, value); + } + } + } + } + } + + this.pregnancyChanceByEntity = cleaned; + } + + public int getPregnancyChanceFor(String entityId) { + if (!this.pregnancyEnabled) { + return 0; + } else if (entityId != null && !entityId.isBlank() && this.pregnancyChanceByEntity != null) { + Integer override = this.pregnancyChanceByEntity.get(entityId); + return override == null ? this.getPregnancyChancePercent() : Math.max(0, Math.min(100, override)); + } else { + return this.getPregnancyChancePercent(); + } + } + + public Map getPregnancyOffspringCountByEntity() { + if (this.pregnancyOffspringCountByEntity == null) { + return Map.of(); + } + + Map copy = new LinkedHashMap<>(); + + for (Entry entry : this.pregnancyOffspringCountByEntity.entrySet()) { + if (entry != null && entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), entry.getValue().sanitized()); + } + } + + return copy; + } + + public void setPregnancyOffspringCountByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + NonConfig.OffspringCountRange range = entry.getValue() == null ? new NonConfig.OffspringCountRange() : entry.getValue().sanitized(); + cleaned.put(key, range); + } + } + } + } + } + + this.pregnancyOffspringCountByEntity = cleaned; + } + + public NonConfig.OffspringCountRange getPregnancyOffspringCountRangeFor(String entityId) { + if (entityId != null && !entityId.isBlank() && this.pregnancyOffspringCountByEntity != null) { + NonConfig.OffspringCountRange override = this.pregnancyOffspringCountByEntity.get(entityId); + return override == null ? new NonConfig.OffspringCountRange() : override.sanitized(); + } else { + return new NonConfig.OffspringCountRange(); + } + } + + public int getPregnancyEggDefaultHatchSeconds() { + return Math.max(1, Math.min(72000, this.pregnancyEggDefaultHatchSeconds)); + } + + public void setPregnancyEggDefaultHatchSeconds(int seconds) { + this.pregnancyEggDefaultHatchSeconds = Math.max(1, Math.min(72000, seconds)); + } + + public Map getPregnancyEggEnabledByEntity() { + return this.pregnancyEggEnabledByEntity == null ? Map.of() : Map.copyOf(this.pregnancyEggEnabledByEntity); + } + + public void setPregnancyEggEnabledByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + boolean enabled = Boolean.TRUE.equals(entry.getValue()); + cleaned.put(key, enabled); + } + } + } + } + } + + this.pregnancyEggEnabledByEntity = cleaned; + } + + public boolean isPregnancyEggEnabledFor(String entityId) { + return entityId != null && !entityId.isBlank() && this.pregnancyEggEnabledByEntity != null + ? Boolean.TRUE.equals(this.pregnancyEggEnabledByEntity.get(entityId)) + : false; + } + + public Map getPregnancyEggHatchSecondsByEntity() { + return this.pregnancyEggHatchSecondsByEntity == null ? Map.of() : Map.copyOf(this.pregnancyEggHatchSecondsByEntity); + } + + public void setPregnancyEggHatchSecondsByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + int value = entry.getValue() == null ? this.getPregnancyEggDefaultHatchSeconds() : entry.getValue(); + value = Math.max(1, Math.min(72000, value)); + cleaned.put(key, value); + } + } + } + } + } + + this.pregnancyEggHatchSecondsByEntity = cleaned; + } + + public int getPregnancyEggHatchSecondsFor(String entityId) { + int fallback = this.getPregnancyEggDefaultHatchSeconds(); + if (entityId != null && !entityId.isBlank() && this.pregnancyEggHatchSecondsByEntity != null) { + Integer override = this.pregnancyEggHatchSecondsByEntity.get(entityId); + return override == null ? fallback : Math.max(1, Math.min(72000, override)); + } else { + return fallback; + } + } + + public boolean isPregnancyAutoTameMobs() { + return this.pregnancyAutoTameMobs; + } + + public void setPregnancyAutoTameMobs(boolean value) { + this.pregnancyAutoTameMobs = value; + } + + public boolean pregnancyFriendlyMobsIgnorePlayers() { + return this.pregnancyFriendlyMobsIgnorePlayers; + } + + public void setPregnancyFriendlyMobsIgnorePlayers(boolean value) { + this.pregnancyFriendlyMobsIgnorePlayers = value; + } + + public Map getEntityProfilesByEntity() { + if (this.entityProfilesByEntity == null) { + return Map.of(); + } + + Map copy = new LinkedHashMap<>(); + + for (Entry entry : this.entityProfilesByEntity.entrySet()) { + if (entry != null && entry.getKey() != null && entry.getValue() != null) { + String key = entry.getKey().trim(); + if (!key.isEmpty()) { + NonConfig.EntityProfile profile = entry.getValue().sanitized(); + if (!profile.isEmpty()) { + copy.put(key, profile); + } + } + } + } + + return copy; + } + + public void setEntityProfilesByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null && entry.getKey() != null && entry.getValue() != null) { + String key = entry.getKey().trim(); + if (!key.isEmpty()) { + NonConfig.EntityProfile profile = entry.getValue().sanitized(); + if (!profile.isEmpty()) { + cleaned.put(key, profile); + } + } + } + } + } + + this.entityProfilesByEntity = cleaned; + NonEntityProfiles.invalidateEnergyGainMultiplierCache(); + } + + public double getLiquidDecayPerSecond() { + return Math.max(0.0, Math.min(20.0, this.liquidDecayPerSecond)); + } + + public void setLiquidDecayPerSecond(double decay) { + this.liquidDecayPerSecond = Math.max(0.0, Math.min(20.0, decay)); + } + + public int getLiquidPuddleDespawnSeconds() { + return Math.max(1, Math.min(300, this.liquidPuddleDespawnSeconds)); + } + + public void setLiquidPuddleDespawnSeconds(int seconds) { + this.liquidPuddleDespawnSeconds = Math.max(1, Math.min(300, seconds)); + } + + public double getPeakXpPerMl() { + return Math.max(0.0, Math.min(10.0, this.peakXpPerMl)); + } + + public void setPeakXpPerMl(double value) { + this.peakXpPerMl = Math.max(0.0, Math.min(10.0, value)); + } + + public float getFilledStageOneSpeedMult() { + return this.clampMultiplier(this.filledStageOneSpeedMult); + } + + public void setFilledStageOneSpeedMult(float value) { + this.filledStageOneSpeedMult = this.clampMultiplier(value); + } + + public float getFilledStageTwoSpeedMult() { + return this.clampMultiplier(this.filledStageTwoSpeedMult); + } + + public void setFilledStageTwoSpeedMult(float value) { + this.filledStageTwoSpeedMult = this.clampMultiplier(value); + } + + public float getFilledStageThreeSpeedMult() { + return this.clampMultiplier(this.filledStageThreeSpeedMult); + } + + public void setFilledStageThreeSpeedMult(float value) { + this.filledStageThreeSpeedMult = this.clampMultiplier(value); + } + + public float getFilledStageOneJumpMult() { + return this.clampMultiplier(this.filledStageOneJumpMult); + } + + public void setFilledStageOneJumpMult(float value) { + this.filledStageOneJumpMult = this.clampMultiplier(value); + } + + public float getFilledStageTwoJumpMult() { + return this.clampMultiplier(this.filledStageTwoJumpMult); + } + + public void setFilledStageTwoJumpMult(float value) { + this.filledStageTwoJumpMult = this.clampMultiplier(value); + } + + public float getFilledStageThreeJumpMult() { + return this.clampMultiplier(this.filledStageThreeJumpMult); + } + + public void setFilledStageThreeJumpMult(float value) { + this.filledStageThreeJumpMult = this.clampMultiplier(value); + } + + public int getUiEnergyOffsetX() { + return this.clampUiOffset(this.uiEnergyOffsetX); + } + + public void setUiEnergyOffsetX(int value) { + this.uiEnergyOffsetX = this.clampUiOffset(value); + } + + public int getUiEnergyOffsetY() { + return this.clampUiOffset(this.uiEnergyOffsetY); + } + + public void setUiEnergyOffsetY(int value) { + this.uiEnergyOffsetY = this.clampUiOffset(value); + } + + public int getUiEnergyHeartOffsetX() { + return this.clampUiOffset(this.uiEnergyHeartOffsetX); + } + + public void setUiEnergyHeartOffsetX(int value) { + this.uiEnergyHeartOffsetX = this.clampUiOffset(value); + } + + public int getUiEnergyHeartOffsetY() { + return this.clampUiOffset(this.uiEnergyHeartOffsetY); + } + + public void setUiEnergyHeartOffsetY(int value) { + this.uiEnergyHeartOffsetY = this.clampUiOffset(value); + } + + public int getUiAttackOffsetX() { + return this.clampUiOffset(this.uiAttackOffsetX); + } + + public void setUiAttackOffsetX(int value) { + this.uiAttackOffsetX = this.clampUiOffset(value); + } + + public int getUiAttackOffsetY() { + return this.clampUiOffset(this.uiAttackOffsetY); + } + + public void setUiAttackOffsetY(int value) { + this.uiAttackOffsetY = this.clampUiOffset(value); + } + + public int getUiPromptOffsetX() { + return this.clampUiOffset(this.uiPromptOffsetX); + } + + public void setUiPromptOffsetX(int value) { + this.uiPromptOffsetX = this.clampUiOffset(value); + } + + public int getUiPromptOffsetY() { + return this.clampUiOffset(this.uiPromptOffsetY); + } + + public void setUiPromptOffsetY(int value) { + this.uiPromptOffsetY = this.clampUiOffset(value); + } + + public int getUiLiquidOffsetX() { + return this.clampUiOffset(this.uiLiquidOffsetX); + } + + public void setUiLiquidOffsetX(int value) { + this.uiLiquidOffsetX = this.clampUiOffset(value); + } + + public int getUiLiquidOffsetY() { + return this.clampUiOffset(this.uiLiquidOffsetY); + } + + public void setUiLiquidOffsetY(int value) { + this.uiLiquidOffsetY = this.clampUiOffset(value); + } + + public boolean isUiEnergyVisible() { + return this.uiEnergyVisible; + } + + public void setUiEnergyVisible(boolean value) { + this.uiEnergyVisible = value; + } + + public boolean isUiEnergyHeartVisible() { + return this.uiEnergyHeartVisible; + } + + public void setUiEnergyHeartVisible(boolean value) { + this.uiEnergyHeartVisible = value; + } + + public boolean isUiAttackVisible() { + return this.uiAttackVisible; + } + + public void setUiAttackVisible(boolean value) { + this.uiAttackVisible = value; + } + + public boolean isUiPromptVisible() { + return this.uiPromptVisible; + } + + public void setUiPromptVisible(boolean value) { + this.uiPromptVisible = value; + } + + public boolean isUiLiquidVisible() { + return this.uiLiquidVisible; + } + + public void setUiLiquidVisible(boolean value) { + this.uiLiquidVisible = value; + } + + public boolean isDebugSpinMode() { + return this.debugSpinMode; + } + + public void setDebugSpinMode(boolean value) { + this.debugSpinMode = value; + } + + public String getDestroyedSkinTintHex() { + return sanitizeHexColor(this.destroyedSkinTintHex); + } + + public void setDestroyedSkinTintHex(String value) { + this.destroyedSkinTintHex = sanitizeHexColor(value); + } + + public boolean hasDestroyedSkinTintOverride() { + return !this.getDestroyedSkinTintHex().isEmpty(); + } + + public int getDestroyedSkinTintRgb(int fallbackRgb) { + String hex = this.getDestroyedSkinTintHex(); + if (hex.isEmpty()) { + return fallbackRgb & 16777215; + } + + try { + return Integer.parseUnsignedInt(hex, 16) & 16777215; + } catch (NumberFormatException e) { + return fallbackRgb & 16777215; + } + } + + public Map getGenderSpawnByEntity() { + if (this.genderSpawnByEntity == null) { + return Map.of(); + } + + Map out = new LinkedHashMap<>(); + + for (Entry entry : this.genderSpawnByEntity.entrySet()) { + if (entry != null) { + String key = sanitizeKey(entry.getKey()); + if (key != null) { + NonConfig.GenderSpawnChances chances = sanitizeGenderSpawnChances(entry.getValue()); + if (chances != null) { + out.put(key, chances); + } + } + } + } + + return Map.copyOf(out); + } + + public void setGenderSpawnByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = sanitizeKey(entry.getKey()); + if (key != null) { + NonConfig.GenderSpawnChances chances = sanitizeGenderSpawnChances(entry.getValue()); + if (chances != null) { + cleaned.put(key, chances); + } + } + } + } + } + + this.genderSpawnByEntity = cleaned; + } + + public Map getLiquidGainByEntity() { + return this.liquidGainByEntity == null ? Map.of() : Map.copyOf(this.liquidGainByEntity); + } + + public int getLiquidGainFor(String entityId) { + if (entityId != null && this.liquidGainByEntity != null) { + Integer value = this.liquidGainByEntity.get(entityId); + return value == null ? 0 : Math.max(0, Math.min(1000, value)); + } else { + return 0; + } + } + + public void setLiquidGainByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + int value = entry.getValue() == null ? 0 : entry.getValue(); + value = Math.max(0, Math.min(1000, value)); + cleaned.put(key, value); + } + } + } + } + } + + this.liquidGainByEntity = cleaned; + } + + public Map getLiquidColorByEntity() { + return this.liquidColorByEntity == null ? Map.of() : Map.copyOf(this.liquidColorByEntity); + } + + public void setLiquidColorByEntity(Map entries) { + Map cleaned = new LinkedHashMap<>(); + if (entries != null) { + for (Entry entry : entries.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + if (key != null) { + key = key.trim(); + if (!key.isEmpty()) { + String normalizedHex = NonLiquidColors.normalizeHexColor(entry.getValue()); + if (normalizedHex != null) { + cleaned.put(key, normalizedHex); + } + } + } + } + } + } + + this.liquidColorByEntity = cleaned; + } + + public Integer getLiquidColorOverrideRgb(String entityId) { + return entityId != null && this.liquidColorByEntity != null ? NonLiquidColors.resolveEntityOverrideFromMap(this.liquidColorByEntity, entityId) : null; + } + + public List getExternalNoNPackLoadOrder() { + return this.externalNoNPackLoadOrder == null ? List.of() : List.copyOf(this.externalNoNPackLoadOrder); + } + + public void setExternalNoNPackLoadOrder(List order) { + this.externalNoNPackLoadOrder = sanitizePackLoadOrder(order); + } + + public List getDisabledAnimationPacks() { + return this.disabledAnimationPacks == null ? List.of() : List.copyOf(this.disabledAnimationPacks); + } + + public void setDisabledAnimationPacks(List packIds) { + this.disabledAnimationPacks = sanitizeStringList(packIds); + } + + public List getDisabledAnimations() { + return this.disabledAnimations == null ? List.of() : List.copyOf(this.disabledAnimations); + } + + public void setDisabledAnimations(List animationIds) { + this.disabledAnimations = sanitizeStringList(animationIds); + } + + public List getAcknowledgedStartupWarnings() { + return this.acknowledgedStartupWarnings == null ? List.of() : List.copyOf(this.acknowledgedStartupWarnings); + } + + public void setAcknowledgedStartupWarnings(List warningIds) { + this.acknowledgedStartupWarnings = sanitizeAcknowledgedStartupWarnings(warningIds); + } + + public boolean isStartupWarningAcknowledged(String warningId) { + String normalized = sanitizeWarningId(warningId); + return normalized != null && this.getAcknowledgedStartupWarnings().contains(normalized); + } + + public boolean acknowledgeStartupWarning(String warningId) { + String normalized = sanitizeWarningId(warningId); + if (normalized == null) { + return false; + } + + LinkedHashSet merged = new LinkedHashSet<>(this.getAcknowledgedStartupWarnings()); + if (!merged.add(normalized)) { + return false; + } + + this.setAcknowledgedStartupWarnings(new ArrayList<>(merged)); + return true; + } + + private static List sanitizePackLoadOrder(List order) { + if (order != null && !order.isEmpty()) { + LinkedHashSet cleaned = new LinkedHashSet<>(); + + for (String raw : order) { + if (raw != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + cleaned.add(trimmed); + } + } + } + + return new ArrayList<>(cleaned); + } else { + return new ArrayList<>(); + } + } + + private static List sanitizeStringList(List values) { + if (values != null && !values.isEmpty()) { + LinkedHashSet cleaned = new LinkedHashSet<>(); + + for (String raw : values) { + if (raw != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + cleaned.add(trimmed); + } + } + } + + return new ArrayList<>(cleaned); + } else { + return new ArrayList<>(); + } + } + + private static List sanitizeAcknowledgedStartupWarnings(List warningIds) { + if (warningIds != null && !warningIds.isEmpty()) { + LinkedHashSet cleaned = new LinkedHashSet<>(); + + for (String raw : warningIds) { + String normalized = sanitizeWarningId(raw); + if (normalized != null) { + cleaned.add(normalized); + } + } + + return cleaned.stream().sorted(String.CASE_INSENSITIVE_ORDER).collect(Collectors.toCollection(ArrayList::new)); + } else { + return new ArrayList<>(); + } + } + + private static String sanitizeWarningId(String raw) { + if (raw == null) { + return null; + } + + String value = raw.trim(); + return value.isEmpty() ? null : value; + } + + private int clampUiOffset(int value) { + return Math.max(-999, Math.min(999, value)); + } + + private float clampMultiplier(float value) { + return !Float.isFinite(value) ? 1.0F : Math.max(0.1F, Math.min(1.0F, value)); + } + + private static int clampPercent(int value) { + return Math.max(0, Math.min(100, value)); + } + + private static int clampInt(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static int valueOrDefault(int value, int fallback) { + return value <= 0 ? fallback : value; + } + + private static String sanitizeIntifaceServerUrl(String raw) { + if (raw != null && !raw.isBlank()) { + String value = raw.trim(); + + try { + URI uri = new URI(value); + String scheme = uri.getScheme(); + if (scheme == null) { + return "ws://127.0.0.1:12345"; + } else if (!scheme.equalsIgnoreCase("ws") && !scheme.equalsIgnoreCase("wss")) { + return "ws://127.0.0.1:12345"; + } else { + return uri.getHost() != null && !uri.getHost().isBlank() ? value : "ws://127.0.0.1:12345"; + } + } catch (URISyntaxException e) { + return "ws://127.0.0.1:12345"; + } + } else { + return "ws://127.0.0.1:12345"; + } + } + + private static float clampFloat(float value, float min, float max) { + return !Float.isFinite(value) ? min : Math.max(min, Math.min(max, value)); + } + + public static NonConfig.GenderSpawnChances defaultGenderSpawnChances() { + return new NonConfig.GenderSpawnChances(48, 47, 5); + } + + public static NonConfig.GenderSpawnChances validOrDefault(NonConfig.GenderSpawnChances chances) { + NonConfig.GenderSpawnChances safe = sanitizeGenderSpawnChances(chances); + return safe == null ? defaultGenderSpawnChances() : safe; + } + + public static NonConfig.GenderSpawnChances sanitizeGenderSpawnChances(NonConfig.GenderSpawnChances chances) { + if (chances == null) { + return null; + } + + NonConfig.GenderSpawnChances safe = chances.sanitized(); + return safe.isValid() ? safe : null; + } + + public static String normalizeBirthMode(String value) { + if (value == null) { + return null; + } else { + String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("egg".equals(normalized)) { + return "egg"; + } else { + return "direct".equals(normalized) ? "direct" : null; + } + } + } + + public static String normalizeEntityIdString(String value) { + if (value == null) { + return null; + } + + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + public static String normalizeTextureIdString(String value) { + if (value == null) { + return null; + } + + String normalized = value.trim(); + if (normalized.isEmpty()) { + return null; + } + + Identifier parsed = Identifier.tryParse(normalized); + return parsed != null && !parsed.getPath().isBlank() ? normalized : null; + } + + private static String sanitizeKey(String raw) { + if (raw == null) { + return null; + } + + String key = raw.trim(); + return key.isEmpty() ? null : key; + } + + private static String sanitizeHexColor(String raw) { + if (raw == null) { + return ""; + } + + String value = raw.trim(); + if (value.startsWith("#")) { + value = value.substring(1); + } + + value = value.toUpperCase(Locale.ROOT); + if (value.isEmpty()) { + return ""; + } else { + return value.matches("[0-9A-F]{6}") ? value : ""; + } + } + + private static Map defaultPregnancyEggEnabledMap() { + Map map = new LinkedHashMap<>(); + map.put("minecraft:spider", true); + map.put("minecraft:cave_spider", true); + return map; + } + + private static Map defaultPregnancyOffspringCountMap() { + Map map = new LinkedHashMap<>(); + map.put("minecraft:spider", new NonConfig.OffspringCountRange(1, 3)); + map.put("minecraft:cave_spider", new NonConfig.OffspringCountRange(2, 5)); + map.put("minecraft:wolf", new NonConfig.OffspringCountRange(1, 2)); + map.put("minecraft:horse", new NonConfig.OffspringCountRange(1, 1)); + map.put("minecraft:fox", new NonConfig.OffspringCountRange(1, 2)); + map.put("minecraft:dolphin", new NonConfig.OffspringCountRange(1, 1)); + map.put("minecraft:pig", new NonConfig.OffspringCountRange(1, 3)); + return map; + } + + private static Map defaultGenderSpawnByEntityMap() { + Map map = new LinkedHashMap<>(); + NonConfig.GenderSpawnChances maleOnly = new NonConfig.GenderSpawnChances(100, 0, 0); + map.put("minecraft:spider", maleOnly); + map.put("minecraft:cave_spider", maleOnly); + return map; + } + + private void ensureDefaults() { + if (this.debugChatMode == null) { + this.debugChatMode = NonDebugChatMode.SETUP_ERRORS.id(); + } else { + this.debugChatMode = this.debugChatMode().id(); + } + + this.animationBlockSearchRadius = this.blockSearchRadius(); + this.pregnancyChancePercent = this.getPregnancyChancePercent(); + this.pregnancyDurationMinutes = this.getPregnancyDurationMinutes(); + this.pregnancyRiskyStartDay = this.getPregnancyRiskyStartDay(); + this.pregnancyOvulationEndDay = this.getPregnancyOvulationEndDay(); + this.pregnancySafeDayChancePercent = this.getPregnancySafeDayChancePercent(); + if (this.pregnancyRiskyChancePercent == null) { + if (this.pregnancyOvulationChancePercent == 500) { + this.pregnancyRiskyChancePercent = 300; + } else { + this.pregnancyRiskyChancePercent = clampInt(this.pregnancyOvulationChancePercent, 0, 1000); + this.pregnancyOvulationChancePercent = 500; + } + } + + this.pregnancyRiskyChancePercent = this.getPregnancyRiskyChancePercent(); + this.pregnancyOvulationChancePercent = this.getPregnancyOvulationChancePercent(); + this.pregnancyChanceByEntity = new LinkedHashMap<>(); + this.pregnancyOffspringCountByEntity = new LinkedHashMap<>(); + this.pregnancyEggDefaultHatchSeconds = this.getPregnancyEggDefaultHatchSeconds(); + this.pregnancyEggEnabledByEntity = new LinkedHashMap<>(); + this.pregnancyEggHatchSecondsByEntity = new LinkedHashMap<>(); + this.pregnancyAutoTameMobs = this.isPregnancyAutoTameMobs(); + this.pregnancyFriendlyMobsIgnorePlayers = this.pregnancyFriendlyMobsIgnorePlayers(); + this.setEntityProfilesByEntity(this.entityProfilesByEntity); + Map mergedGains = new LinkedHashMap<>(); + if (this.liquidGainByEntity != null) { + this.setLiquidGainByEntity(this.liquidGainByEntity); + mergedGains.putAll(this.liquidGainByEntity); + } + + this.liquidGainByEntity = mergedGains; + Map mergedColors = new LinkedHashMap<>(); + if (this.liquidColorByEntity != null) { + this.setLiquidColorByEntity(this.liquidColorByEntity); + mergedColors.putAll(this.liquidColorByEntity); + } + + this.liquidColorByEntity = mergedColors; + this.setExternalNoNPackLoadOrder(this.externalNoNPackLoadOrder); + this.setDisabledAnimationPacks(this.disabledAnimationPacks); + this.setDisabledAnimations(this.disabledAnimations); + this.destroyedSkinTintHex = this.getDestroyedSkinTintHex(); + this.liquidPuddleDespawnSeconds = this.getLiquidPuddleDespawnSeconds(); + this.peakXpPerMl = this.getPeakXpPerMl(); + this.initialEnergyMax = this.getInitialEnergyMax(); + this.energyGainRate = this.getEnergyGainRate(); + this.nearAnimationEnergyGainMult = this.getNearAnimationEnergyGainMult(); + this.playerEnergyAuraMultLow = this.getPlayerEnergyAuraMultLow(); + this.playerEnergyAuraMultMid = this.getPlayerEnergyAuraMultMid(); + this.playerEnergyAuraMultHigh = this.getPlayerEnergyAuraMultHigh(); + this.destroyedSkinAuraMultStage1 = this.getDestroyedSkinAuraMultStage1(); + this.destroyedSkinAuraMultStage2 = this.getDestroyedSkinAuraMultStage2(); + this.destroyedSkinAuraMultStage3 = this.getDestroyedSkinAuraMultStage3(); + this.destroyedSkinAuraMultStage4 = this.getDestroyedSkinAuraMultStage4(); + this.normalDamageDestroyedSkinChancePercent = this.getNormalDamageDestroyedSkinChancePercent(); + this.lastDefeatedEnabled = this.isLastDefeatedEnabled(); + this.lastDefeatedEnergyThreshold = this.getLastDefeatedEnergyThreshold(); + this.lastDefeatedSearchRadius = this.getLastDefeatedSearchRadius(); + this.lastDefeatedCooldownSeconds = this.getLastDefeatedCooldownSeconds(); + this.playerEnergyAuraRadiusMin = this.getPlayerEnergyAuraRadiusMin(); + this.playerEnergyAuraRadiusMax = this.getPlayerEnergyAuraRadiusMax(); + this.playerEnergyAuraPulseTicks = this.getPlayerEnergyAuraPulseTicks(); + this.genderSpawnByEntity = new LinkedHashMap<>(); + this.setGlobalGenderSpawnChances(this.getGlobalGenderSpawnChances()); + this.maleMaleChancePercent = this.getMaleMaleChancePercent(); + this.femaleFemaleChancePercent = this.getFemaleFemaleChancePercent(); + this.entityEntityChancePercent = this.getEntityEntityChancePercent(); + this.multiActorJoinChancePercent = this.getMultiActorJoinChancePercent(); + this.setPlayerGenderSelection(this.getPlayerGenderSelection()); + this.allowPlayerGenderChangeAnytime = this.allowPlayerGenderChangeAnytime(); + this.requirePlayerGenderSelectionOnJoin = this.requirePlayerGenderSelectionOnJoin(); + this.setAllowedStartingGenderMask(this.allowedStartingGenderMask); + this.setAllowedPlayerInteractionProfilesMask(this.allowedPlayerInteractionProfilesMask); + this.playerRequestMinEnergy = this.getPlayerRequestMinEnergy(); + this.setAcknowledgedStartupWarnings(this.acknowledgedStartupWarnings); + this.messSystemEnabled = this.isMessSystemEnabled(); + this.destroyedSkinSystemEnabled = this.isDestroyedSkinSystemEnabled(); + this.allowCraftingTableSkinRepair = this.isCraftingTableSkinRepairAllowed(); + this.keepMessAfterDeath = this.keepMessAfterDeath(); + this.keepRippedSkinAfterDeath = this.keepRippedSkinAfterDeath(); + this.keepLiquidTankAfterDeath = this.keepLiquidTankAfterDeath(); + this.keepPregnancyAfterDeath = this.keepPregnancyAfterDeath(); + this.armorShedEffectEnabled = this.isArmorShedEffectEnabled(); + this.syncFemaleGenderModWithNonGender = this.isSyncFemaleGenderModWithNonGender(); + this.destroyedSkinFemaleGenderOverridesEnabled = this.isDestroyedSkinFemaleGenderOverridesEnabled(); + this.setFemaleGenderDestroyedLow(this.femaleGenderDestroyedLow); + this.setFemaleGenderDestroyedHigh(this.femaleGenderDestroyedHigh); + this.actionSoundVolumePercent = this.getActionSoundVolumePercent(); + this.intifaceServerUrl = this.getIntifaceServerUrl(); + this.intifaceReactiveImpactStrengthPercent = this.getIntifaceReactiveImpactStrengthPercent(); + this.intifaceReactiveImpactDurationMs = this.getIntifaceReactiveImpactDurationMs(); + this.intifacePeakReactiveImpactStrengthPercent = this.getIntifacePeakReactiveImpactStrengthPercent(); + this.intifacePeakReactiveImpactDurationMs = this.getIntifacePeakReactiveImpactDurationMs(); + this.intifaceCooldownMs = this.getIntifaceCooldownMs(); + this.intifaceLatencyCompensationMs = this.getIntifaceLatencyCompensationMs(); + this.intifaceAnimationBaselineStrengthPercent = this.getIntifaceAnimationBaselineStrengthPercent(); + this.intifaceOscillatorRegularSpeedPercent = this.getIntifaceOscillatorRegularSpeedPercent(); + this.intifaceOscillatorPeakSpeedPercent = this.getIntifaceOscillatorPeakSpeedPercent(); + this.intifaceStrokerMaxDistancePercent = this.getIntifaceStrokerMaxDistancePercent(); + this.intifaceStrokerMinDistancePercent = this.getIntifaceStrokerMinDistancePercent(); + this.intifaceStrokerRegularMoveDurationMs = this.getIntifaceStrokerRegularMoveDurationMs(); + this.intifaceStrokerPeakMoveDurationMs = this.getIntifaceStrokerPeakMoveDurationMs(); + this.intifaceEnergizedOneBasePercent = this.getIntifaceEnergizedBasePercent(1); + this.intifaceEnergizedOnePulsePercent = this.getIntifaceEnergizedPulsePercent(1); + this.intifaceEnergizedTwoBasePercent = this.getIntifaceEnergizedBasePercent(2); + this.intifaceEnergizedTwoPulsePercent = this.getIntifaceEnergizedPulsePercent(2); + this.intifaceEnergizedThreeBasePercent = this.getIntifaceEnergizedBasePercent(3); + this.intifaceEnergizedThreePulsePercent = this.getIntifaceEnergizedPulsePercent(3); + this.attackEscapeHits = this.getAttackEscapeHits(); + this.attackDecayPerSecond = this.getAttackDecayPerSecond(); + this.attackEscapeDamageDifficultyPercent = this.getAttackEscapeDamageDifficultyPercent(); + this.pvpFullHealthEscapeBonusPercent = this.getPvpFullHealthEscapeBonusPercent(); + this.pvpCriticalHealthMashStrengthPercent = this.getPvpCriticalHealthMashStrengthPercent(); + this.pvpMaxHeatEscapeStrengthPercent = this.getPvpMaxHeatEscapeStrengthPercent(); + this.pvpZeroHeatAttackStrengthPercent = this.getPvpZeroHeatAttackStrengthPercent(); + if (this.attackEscapeInvulnerabilityTicks != null) { + this.attackEscapeInvulnerabilitySeconds = ticksToSeconds(this.attackEscapeInvulnerabilityTicks); + this.attackEscapeInvulnerabilityTicks = null; + } + + this.attackEscapeInvulnerabilitySeconds = this.getAttackEscapeInvulnerabilitySeconds(); + this.attackEscapeAnimationProtectionSeconds = this.getAttackEscapeAnimationProtectionSeconds(); + this.postEscapeGatherMaxMobs = this.getPostEscapeGatherMaxMobs(); + this.blockAnimationsWhileRidingLivingEntities = this.blockAnimationsWhileRidingLivingEntities(); + this.blockAnimationsWhileRidingVehicles = this.blockAnimationsWhileRidingVehicles(); + this.attackOutcomeFailsafeThreshold = this.getAttackOutcomeFailsafeThreshold(); + this.filledStageOneSpeedMult = this.clampMultiplier(this.filledStageOneSpeedMult); + this.filledStageTwoSpeedMult = this.clampMultiplier(this.filledStageTwoSpeedMult); + this.filledStageThreeSpeedMult = this.clampMultiplier(this.filledStageThreeSpeedMult); + this.filledStageOneJumpMult = this.clampMultiplier(this.filledStageOneJumpMult); + this.filledStageTwoJumpMult = this.clampMultiplier(this.filledStageTwoJumpMult); + this.filledStageThreeJumpMult = this.clampMultiplier(this.filledStageThreeJumpMult); + } + + public static NonConfig load() { + NonConfig cfg = new NonConfig(); + boolean saveNormalizedConfig = !Files.isRegularFile(PATH); + if (Files.isRegularFile(PATH)) { + try (Reader reader = Files.newBufferedReader(PATH, StandardCharsets.UTF_8)) { + NonConfig fromDisk = (NonConfig)GSON.fromJson(reader, NonConfig.class); + if (fromDisk != null) { + cfg = fromDisk; + } + + saveNormalizedConfig = true; + } catch (JsonParseException | IllegalStateException e) { + Path backup = preserveInvalidConfig(); + NeedsOfNature.LOGGER + .warn( + "[NoN] Invalid config at {}. Using defaults{}.", new Object[]{PATH, backup == null ? "" : " and preserving the invalid file as " + backup, e} + ); + saveNormalizedConfig = backup != null; + } catch (IOException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to read config at {}. Using defaults.", PATH, e); + } + } + + cfg.ensureDefaults(); + if (saveNormalizedConfig) { + cfg.save(); + } + + return cfg; + } + + public NonConfig copy() { + NonConfig copy = (NonConfig)GSON.fromJson(GSON.toJson(this), NonConfig.class); + if (copy == null) { + copy = new NonConfig(); + } + + copy.ensureDefaults(); + return copy; + } + + public void save() { + Path temporary = PATH.resolveSibling(PATH.getFileName() + ".tmp"); + + try { + Files.createDirectories(PATH.getParent()); + + try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { + GSON.toJson(this, writer); + } + + try { + Files.move(temporary, PATH, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, PATH, StandardCopyOption.REPLACE_EXISTING); + } + + NeedsOfNature.onConfigSaved(this); + } catch (IOException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to save config at {}.", PATH, e); + + try { + Files.deleteIfExists(temporary); + } catch (IOException var5) { + } + } + } + + private static Path preserveInvalidConfig() { + Path backup = PATH.resolveSibling(PATH.getFileName() + ".invalid-" + System.currentTimeMillis() + ".json"); + + try { + Files.move(PATH, backup, StandardCopyOption.REPLACE_EXISTING); + return backup; + } catch (IOException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to preserve invalid config at {}.", PATH, e); + return null; + } + } + + public static final class EggProfile { + private Float startSize; + private Float endSize; + private String texture; + private Float health; + + public EggProfile() { + } + + public EggProfile(Float startSize, Float endSize, String texture, Float health) { + this.startSize = startSize; + this.endSize = endSize; + this.texture = texture; + this.health = health; + } + + public static NonConfig.EggProfile defaults() { + return new NonConfig.EggProfile(0.5F, 1.0F, null, 2.0F); + } + + public Float startSize() { + return this.startSize == null ? null : NonConfig.clampFloat(this.startSize, 0.05F, 4.0F); + } + + public Float endSize() { + return this.endSize == null ? null : NonConfig.clampFloat(this.endSize, 0.05F, 4.0F); + } + + public String texture() { + return NonConfig.normalizeTextureIdString(this.texture); + } + + public Float health() { + return this.health == null ? null : NonConfig.clampFloat(this.health, 0.5F, 100.0F); + } + + public NonConfig.EggProfile sanitized() { + return new NonConfig.EggProfile(this.startSize(), this.endSize(), this.texture(), this.health()); + } + + public NonConfig.EggProfile resolvedOver(NonConfig.EggProfile fallback) { + NonConfig.EggProfile base = fallback == null ? defaults() : fallback.sanitized(); + NonConfig.EggProfile safe = this.sanitized(); + return new NonConfig.EggProfile( + safe.startSize() == null ? base.startSize() : safe.startSize(), + safe.endSize() == null ? base.endSize() : safe.endSize(), + safe.texture() == null ? base.texture() : safe.texture(), + safe.health() == null ? base.health() : safe.health() + ) + .sanitized(); + } + + public boolean isEmpty() { + return this.startSize() == null && this.endSize() == null && this.texture() == null && this.health() == null; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if (!(obj instanceof NonConfig.EggProfile other)) { + return false; + } else { + NonConfig.EggProfile a = this.sanitized(); + NonConfig.EggProfile b = other.sanitized(); + return Objects.equals(a.startSize, b.startSize) + && Objects.equals(a.endSize, b.endSize) + && Objects.equals(a.texture, b.texture) + && Objects.equals(a.health, b.health); + } + } + + @Override + public int hashCode() { + NonConfig.EggProfile safe = this.sanitized(); + return Objects.hash(safe.startSize, safe.endSize, safe.texture, safe.health); + } + } + + public static final class EntityProfile { + private Integer pregnancyChancePercent; + private Integer offspringMin; + private Integer offspringMax; + private String birthEntity; + private String birthMode; + private NonConfig.GenderSpawnChances genderSpawn; + private NonConfig.EggProfile egg; + private Boolean lookAtNearbyAnimations; + private Integer multiActorJoinChancePercent; + private Float energyGainMultiplier; + + public EntityProfile() { + } + + public EntityProfile( + Integer pregnancyChancePercent, + Integer offspringMin, + Integer offspringMax, + String birthEntity, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn + ) { + this(pregnancyChancePercent, offspringMin, offspringMax, birthEntity, birthMode, genderSpawn, null, null, null, null); + } + + public EntityProfile( + Integer pregnancyChancePercent, + Integer offspringMin, + Integer offspringMax, + String birthEntity, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn, + NonConfig.EggProfile egg + ) { + this(pregnancyChancePercent, offspringMin, offspringMax, birthEntity, birthMode, genderSpawn, egg, null, null, null); + } + + public EntityProfile( + Integer pregnancyChancePercent, + Integer offspringMin, + Integer offspringMax, + String birthEntity, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn, + NonConfig.EggProfile egg, + Boolean lookAtNearbyAnimations, + Integer multiActorJoinChancePercent, + Float energyGainMultiplier + ) { + this.pregnancyChancePercent = pregnancyChancePercent; + this.offspringMin = offspringMin; + this.offspringMax = offspringMax; + this.birthEntity = birthEntity; + this.birthMode = birthMode; + this.genderSpawn = genderSpawn; + this.egg = egg; + this.lookAtNearbyAnimations = lookAtNearbyAnimations; + this.multiActorJoinChancePercent = multiActorJoinChancePercent; + this.energyGainMultiplier = energyGainMultiplier; + } + + public static NonConfig.EntityProfile full( + int pregnancyChancePercent, int offspringMin, int offspringMax, String birthEntity, String birthMode, NonConfig.GenderSpawnChances genderSpawn + ) { + return full(pregnancyChancePercent, offspringMin, offspringMax, birthEntity, birthMode, genderSpawn, null, true, null, 1.0F); + } + + public static NonConfig.EntityProfile full( + int pregnancyChancePercent, + int offspringMin, + int offspringMax, + String birthEntity, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn, + NonConfig.EggProfile egg + ) { + return full(pregnancyChancePercent, offspringMin, offspringMax, birthEntity, birthMode, genderSpawn, egg, true, null, 1.0F); + } + + public static NonConfig.EntityProfile full( + int pregnancyChancePercent, + int offspringMin, + int offspringMax, + String birthEntity, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn, + NonConfig.EggProfile egg, + boolean lookAtNearbyAnimations, + Integer multiActorJoinChancePercent, + float energyGainMultiplier + ) { + return new NonConfig.EntityProfile( + NonConfig.clampPercent(pregnancyChancePercent), + NonConfig.clampInt(offspringMin, 1, 16), + NonConfig.clampInt(offspringMax, 1, 16), + NonConfig.normalizeEntityIdString(birthEntity), + NonConfig.normalizeBirthMode(birthMode), + NonConfig.validOrDefault(genderSpawn), + egg == null ? null : egg.sanitized(), + lookAtNearbyAnimations, + multiActorJoinChancePercent, + energyGainMultiplier + ) + .sanitized(); + } + + public Integer pregnancyChancePercent() { + return this.pregnancyChancePercent == null ? null : NonConfig.clampPercent(this.pregnancyChancePercent); + } + + public Integer offspringMin() { + return this.offspringMin == null ? null : NonConfig.clampInt(this.offspringMin, 1, 16); + } + + public Integer offspringMax() { + return this.offspringMax == null ? null : NonConfig.clampInt(this.offspringMax, 1, 16); + } + + public String birthEntity() { + return NonConfig.normalizeEntityIdString(this.birthEntity); + } + + public String birthMode() { + return NonConfig.normalizeBirthMode(this.birthMode); + } + + public NonConfig.GenderSpawnChances genderSpawn() { + return NonConfig.sanitizeGenderSpawnChances(this.genderSpawn); + } + + public NonConfig.EggProfile egg() { + NonConfig.EggProfile safe = this.egg == null ? null : this.egg.sanitized(); + return safe != null && !safe.isEmpty() ? safe : null; + } + + public Boolean lookAtNearbyAnimations() { + return this.lookAtNearbyAnimations; + } + + public Integer multiActorJoinChancePercent() { + return this.multiActorJoinChancePercent == null ? null : NonConfig.clampPercent(this.multiActorJoinChancePercent); + } + + public Float energyGainMultiplier() { + return this.energyGainMultiplier == null ? null : NonConfig.clampFloat(this.energyGainMultiplier, 0.1F, 10.0F); + } + + public NonConfig.EntityProfile sanitized() { + Integer safeMin = this.offspringMin(); + Integer safeMax = this.offspringMax(); + if (safeMin != null && safeMax != null && safeMax < safeMin) { + safeMax = safeMin; + } + + return new NonConfig.EntityProfile( + this.pregnancyChancePercent(), + safeMin, + safeMax, + this.birthEntity(), + this.birthMode(), + this.genderSpawn(), + this.egg(), + this.lookAtNearbyAnimations(), + this.multiActorJoinChancePercent(), + this.energyGainMultiplier() + ); + } + + public boolean isEmpty() { + return this.pregnancyChancePercent() == null + && this.offspringMin() == null + && this.offspringMax() == null + && this.birthEntity() == null + && this.birthMode() == null + && this.genderSpawn() == null + && this.egg() == null + && this.lookAtNearbyAnimations() == null + && this.multiActorJoinChancePercent() == null + && this.energyGainMultiplier() == null; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if (!(obj instanceof NonConfig.EntityProfile other)) { + return false; + } else { + NonConfig.EntityProfile a = this.sanitized(); + NonConfig.EntityProfile b = other.sanitized(); + return Objects.equals(a.pregnancyChancePercent, b.pregnancyChancePercent) + && Objects.equals(a.offspringMin, b.offspringMin) + && Objects.equals(a.offspringMax, b.offspringMax) + && Objects.equals(a.birthEntity, b.birthEntity) + && Objects.equals(a.birthMode, b.birthMode) + && Objects.equals(a.genderSpawn, b.genderSpawn) + && Objects.equals(a.egg, b.egg) + && Objects.equals(a.lookAtNearbyAnimations, b.lookAtNearbyAnimations) + && Objects.equals(a.multiActorJoinChancePercent, b.multiActorJoinChancePercent) + && Objects.equals(a.energyGainMultiplier, b.energyGainMultiplier); + } + } + + @Override + public int hashCode() { + NonConfig.EntityProfile safe = this.sanitized(); + return Objects.hash( + safe.pregnancyChancePercent, + safe.offspringMin, + safe.offspringMax, + safe.birthEntity, + safe.birthMode, + safe.genderSpawn, + safe.egg, + safe.lookAtNearbyAnimations, + safe.multiActorJoinChancePercent, + safe.energyGainMultiplier + ); + } + } + + public static final class FemaleGenderDestroyedProfile { + private float breastSize; + private float separation; + private float height; + private float depth; + private float rotation; + private boolean breastPhysics; + private boolean dualPhysics; + private float intensity; + private float momentum; + + public FemaleGenderDestroyedProfile() { + this(0.8F, 0.0F, 0.5F, -0.5F, 0.0F, true, false, 0.25F, 0.625F); + } + + public FemaleGenderDestroyedProfile( + float breastSize, + float separation, + float height, + float depth, + float rotation, + boolean breastPhysics, + boolean dualPhysics, + float intensity, + float momentum + ) { + this.breastSize = NonConfig.clampFloat(breastSize, 0.0F, 1.6F); + this.separation = NonConfig.clampFloat(separation, -1.0F, 1.0F); + this.height = NonConfig.clampFloat(height, -2.0F, 1.0F); + this.depth = NonConfig.clampFloat(depth, -1.0F, 0.0F); + this.rotation = NonConfig.clampFloat(rotation, 0.0F, 0.1F); + this.breastPhysics = breastPhysics; + this.dualPhysics = dualPhysics; + this.intensity = NonConfig.clampFloat(intensity, 0.0F, 0.5F); + this.momentum = NonConfig.clampFloat(momentum, 0.25F, 1.0F); + } + + public static NonConfig.FemaleGenderDestroyedProfile lowDefault() { + return new NonConfig.FemaleGenderDestroyedProfile(0.8F, 0.0F, 0.5F, -0.5F, 0.0F, true, false, 0.25F, 0.625F); + } + + public static NonConfig.FemaleGenderDestroyedProfile highDefault() { + return new NonConfig.FemaleGenderDestroyedProfile(0.8F, -0.1F, 0.2F, 0.0F, 0.06F, true, true, 0.5F, 1.0F); + } + + public NonConfig.FemaleGenderDestroyedProfile sanitized() { + return new NonConfig.FemaleGenderDestroyedProfile( + this.breastSize, this.separation, this.height, this.depth, this.rotation, this.breastPhysics, this.dualPhysics, this.intensity, this.momentum + ); + } + + public float breastSize() { + return NonConfig.clampFloat(this.breastSize, 0.0F, 1.6F); + } + + public float separation() { + return NonConfig.clampFloat(this.separation, -1.0F, 1.0F); + } + + public float height() { + return NonConfig.clampFloat(this.height, -2.0F, 1.0F); + } + + public float depth() { + return NonConfig.clampFloat(this.depth, -1.0F, 0.0F); + } + + public float rotation() { + return NonConfig.clampFloat(this.rotation, 0.0F, 0.1F); + } + + public boolean breastPhysics() { + return this.breastPhysics; + } + + public boolean dualPhysics() { + return this.dualPhysics; + } + + public float intensity() { + return NonConfig.clampFloat(this.intensity, 0.0F, 0.5F); + } + + public float momentum() { + return NonConfig.clampFloat(this.momentum, 0.25F, 1.0F); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else { + return !(obj instanceof NonConfig.FemaleGenderDestroyedProfile other) + ? false + : Float.compare(this.breastSize(), other.breastSize()) == 0 + && Float.compare(this.separation(), other.separation()) == 0 + && Float.compare(this.height(), other.height()) == 0 + && Float.compare(this.depth(), other.depth()) == 0 + && Float.compare(this.rotation(), other.rotation()) == 0 + && this.breastPhysics() == other.breastPhysics() + && this.dualPhysics() == other.dualPhysics() + && Float.compare(this.intensity(), other.intensity()) == 0 + && Float.compare(this.momentum(), other.momentum()) == 0; + } + } + + @Override + public int hashCode() { + int result = Float.hashCode(this.breastSize()); + result = 31 * result + Float.hashCode(this.separation()); + result = 31 * result + Float.hashCode(this.height()); + result = 31 * result + Float.hashCode(this.depth()); + result = 31 * result + Float.hashCode(this.rotation()); + result = 31 * result + Boolean.hashCode(this.breastPhysics()); + result = 31 * result + Boolean.hashCode(this.dualPhysics()); + result = 31 * result + Float.hashCode(this.intensity()); + return 31 * result + Float.hashCode(this.momentum()); + } + } + + public static final class GenderSpawnChances { + private int male; + private int female; + private int both; + + public GenderSpawnChances() { + this(48, 47, 5); + } + + public GenderSpawnChances(int male, int female, int both) { + this.male = NonConfig.clampPercent(male); + this.female = NonConfig.clampPercent(female); + this.both = NonConfig.clampPercent(both); + } + + public int male() { + return NonConfig.clampPercent(this.male); + } + + public int female() { + return NonConfig.clampPercent(this.female); + } + + public int both() { + return NonConfig.clampPercent(this.both); + } + + public boolean isValid() { + return this.male() + this.female() + this.both() == 100; + } + + public NonConfig.GenderSpawnChances sanitized() { + return new NonConfig.GenderSpawnChances(this.male(), this.female(), this.both()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else { + return !(obj instanceof NonConfig.GenderSpawnChances other) + ? false + : this.male() == other.male() && this.female() == other.female() && this.both() == other.both(); + } + } + + @Override + public int hashCode() { + int result = this.male(); + result = 31 * result + this.female(); + return 31 * result + this.both(); + } + } + + public static final class OffspringCountRange { + private int min; + private int max; + + public OffspringCountRange() { + this(1, 1); + } + + public OffspringCountRange(int min, int max) { + int safeMin = NonConfig.clampInt(min, 1, 16); + int safeMax = NonConfig.clampInt(max, 1, 16); + if (safeMax < safeMin) { + safeMax = safeMin; + } + + this.min = safeMin; + this.max = safeMax; + } + + public int min() { + return NonConfig.clampInt(this.min, 1, 16); + } + + public int max() { + return Math.max(this.min(), NonConfig.clampInt(this.max, 1, 16)); + } + + public NonConfig.OffspringCountRange sanitized() { + return new NonConfig.OffspringCountRange(this.min(), this.max()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else { + return !(obj instanceof NonConfig.OffspringCountRange other) ? false : this.min() == other.min() && this.max() == other.max(); + } + } + + @Override + public int hashCode() { + int result = this.min(); + return 31 * result + this.max(); + } + } + + public enum PlayerGenderSelection { + MALE("male", 1), + FEMALE("female", 2), + BOTH("both", 3); + + private final String id; + private final int mask; + + PlayerGenderSelection(String id, int mask) { + this.id = id; + this.mask = mask; + } + + public String id() { + return this.id; + } + + public int mask() { + return this.mask; + } + + public NonConfig.PlayerGenderSelection next() { + return switch (this) { + case MALE -> FEMALE; + case FEMALE -> BOTH; + case BOTH -> MALE; + }; + } + + public static NonConfig.PlayerGenderSelection fromMask(int mask, NonConfig.PlayerGenderSelection fallback) { + int normalized = mask & 3; + + for (NonConfig.PlayerGenderSelection value : values()) { + if (value.mask == normalized) { + return value; + } + } + + return fallback == null ? FEMALE : fallback; + } + + public static NonConfig.PlayerGenderSelection fromId(String id, NonConfig.PlayerGenderSelection fallback) { + if (id != null) { + String normalized = id.trim().toLowerCase(Locale.ROOT); + + for (NonConfig.PlayerGenderSelection value : values()) { + if (value.id.equals(normalized)) { + return value; + } + } + } + + return fallback == null ? FEMALE : fallback; + } + } +} diff --git a/src/main/java/com/nonid/NonDataAccessoryTrinket.java b/src/main/java/com/nonid/NonDataAccessoryTrinket.java new file mode 100644 index 0000000..8c8abbb --- /dev/null +++ b/src/main/java/com/nonid/NonDataAccessoryTrinket.java @@ -0,0 +1,73 @@ +package com.nonid; + +import com.nonid.data.NonAccessoryBehavior; +import com.nonid.data.NonAccessoryDefinitions; +import dev.emi.trinkets.api.SlotReference; +import dev.emi.trinkets.api.Trinket; +import dev.emi.trinkets.api.TrinketComponent; +import dev.emi.trinkets.api.TrinketsApi; +import java.util.Objects; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.util.Pair; + +public final class NonDataAccessoryTrinket implements Trinket { + public static final NonDataAccessoryTrinket INSTANCE = new NonDataAccessoryTrinket(); + + private NonDataAccessoryTrinket() { + } + + public boolean canEquip(ItemStack stack, SlotReference slot, LivingEntity entity) { + if (stack != null && !stack.isEmpty() && slot != null && entity != null) { + NonAccessoryBehavior behavior = NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(stack.getItem())); + String targetSlot = slot.getId(); + return !NonTrinketsIntegration.isSlotAvailableFor(entity, targetSlot) + ? false + : TrinketsApi.getTrinketComponent(entity).map(component -> canEquipWithCurrent(component, stack, slot, behavior, targetSlot)).orElse(true); + } else { + return false; + } + } + + public void onEquip(ItemStack stack, SlotReference slot, LivingEntity entity) { + NonTrinketsCompat.onAccessoryEquipped(entity, slot); + } + + public void onUnequip(ItemStack stack, SlotReference slot, LivingEntity entity) { + NonTrinketsCompat.onAccessoryUnequipped(entity, slot); + } + + private static boolean canEquipWithCurrent( + TrinketComponent component, ItemStack stack, SlotReference target, NonAccessoryBehavior behavior, String targetSlot + ) { + String exclusiveGroup = behavior.exclusiveGroup(); + + for (Pair pair : component.getAllEquipped()) { + SlotReference otherSlot = (SlotReference)pair.getLeft(); + ItemStack otherStack = (ItemStack)pair.getRight(); + if (otherSlot != null && otherStack != null && !otherStack.isEmpty() && !isSameSlot(otherSlot, target)) { + NonAccessoryBehavior otherBehavior = NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(otherStack.getItem())); + if (otherBehavior.occupiesSlot(targetSlot)) { + return false; + } + + for (String occupied : behavior.occupiesSlots()) { + if (NonAccessoryBehavior.isSlot(otherSlot.getId(), occupied)) { + return false; + } + } + + if (exclusiveGroup != null && !exclusiveGroup.isBlank() && exclusiveGroup.equals(otherBehavior.exclusiveGroup())) { + return false; + } + } + } + + return true; + } + + private static boolean isSameSlot(SlotReference first, SlotReference second) { + return first != null && second != null && first.index() == second.index() && Objects.equals(first.getId(), second.getId()); + } +} diff --git a/src/main/java/com/nonid/NonDebugChatCategory.java b/src/main/java/com/nonid/NonDebugChatCategory.java new file mode 100644 index 0000000..f269502 --- /dev/null +++ b/src/main/java/com/nonid/NonDebugChatCategory.java @@ -0,0 +1,9 @@ +package com.nonid; + +public enum NonDebugChatCategory { + INFO, + WARNING, + SETUP, + ERROR, + ALWAYS; +} diff --git a/src/main/java/com/nonid/NonDebugChatMode.java b/src/main/java/com/nonid/NonDebugChatMode.java new file mode 100644 index 0000000..acdffdc --- /dev/null +++ b/src/main/java/com/nonid/NonDebugChatMode.java @@ -0,0 +1,45 @@ +package com.nonid; + +public enum NonDebugChatMode { + ALL("all"), + SETUP_WARNINGS_ERRORS("setup_warnings_errors"), + SETUP_ERRORS("setup_errors"), + ERRORS_ONLY("errors_only"); + + private final String id; + + NonDebugChatMode(String id) { + this.id = id; + } + + public String id() { + return this.id; + } + + public boolean allows(NonDebugChatCategory category) { + if (category == NonDebugChatCategory.ALWAYS) { + return true; + } + + return switch (this) { + case ALL -> true; + case SETUP_WARNINGS_ERRORS -> category == NonDebugChatCategory.SETUP + || category == NonDebugChatCategory.WARNING + || category == NonDebugChatCategory.ERROR; + case SETUP_ERRORS -> category == NonDebugChatCategory.SETUP || category == NonDebugChatCategory.ERROR; + case ERRORS_ONLY -> category == NonDebugChatCategory.ERROR; + }; + } + + public static NonDebugChatMode fromId(String id, NonDebugChatMode fallback) { + if (id != null) { + for (NonDebugChatMode mode : values()) { + if (mode.id.equalsIgnoreCase(id)) { + return mode; + } + } + } + + return fallback; + } +} diff --git a/src/main/java/com/nonid/NonDefeatedAnimationSelector.java b/src/main/java/com/nonid/NonDefeatedAnimationSelector.java new file mode 100644 index 0000000..f43aa1a --- /dev/null +++ b/src/main/java/com/nonid/NonDefeatedAnimationSelector.java @@ -0,0 +1,142 @@ +package com.nonid; + +import com.nonid.data.NonPeakStages; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.util.WeightedPicker; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; +import net.minecraft.entity.Entity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonDefeatedAnimationSelector { + private static final String DEFEATED_TAG_PREFIX = "defeat."; + + private NonDefeatedAnimationSelector() { + } + + @Nullable + static AnimationService.MatchedAnimation find(ServerWorld world, ServerPlayerEntity player, @Nullable Identifier sourceAnimationId) { + if (world != null && player != null && player.getEntityWorld() == world) { + List actors = List.of(player); + List candidates = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : AnimationDefinitions.findActorMatchingCandidates(actors)) { + if (isDefeatedDefinition(definition) && NonInjectorMatchPolicy.allowsAutomaticNonMatch(world, definition, actors)) { + candidates.add(definition); + } + } + + AnimationDefinitions.Definition picked = pickPreferredCandidate( + candidates, + AnimationDefinitions.Definition::contentTags, + AnimationDefinitions.Definition::weight, + sourceContentTags(sourceAnimationId), + world.getRandom().nextDouble() + ); + if (picked == null) { + return null; + } + + List actorKeys = AnimationDefinitions.resolveActorKeys(picked, actors, world.getRandom()); + return actorKeys.size() != actors.size() + ? null + : new AnimationService.MatchedAnimation(picked.id(), List.copyOf(actorKeys), picked.stages() == null ? List.of() : List.copyOf(picked.stages())); + } else { + return null; + } + } + + @Nullable + static T pickPreferredCandidate( + List candidates, + Function> contentTags, + ToDoubleFunction weight, + Collection sourceContentTags, + double randomValue + ) { + if (candidates != null && !candidates.isEmpty() && contentTags != null && weight != null) { + Set sourceTags = normalizeTags(sourceContentTags); + List preferred = new ArrayList<>(); + + for (T candidate : candidates) { + if (candidate != null) { + Set candidateTags = normalizeTags((Collection)contentTags.apply(candidate)); + if (sharesContentTag(sourceTags, candidateTags)) { + preferred.add(candidate); + } + } + } + + List pool = preferred.isEmpty() ? candidates : preferred; + return WeightedPicker.pick(pool, weight, randomValue); + } else { + return null; + } + } + + private static boolean isDefeatedDefinition(AnimationDefinitions.Definition definition) { + if (definition != null && definition.animationTags() != null) { + for (String tag : definition.animationTags()) { + if (tag != null && tag.startsWith("defeat.")) { + return true; + } + } + + return false; + } else { + return false; + } + } + + private static List sourceContentTags(@Nullable Identifier sourceAnimationId) { + if (sourceAnimationId == null) { + return List.of(); + } + + AnimationDefinitions.Definition source = AnimationDefinitions.getDefinition(NonPeakStages.baseAnimationId(sourceAnimationId)); + return source != null && source.contentTags() != null ? source.contentTags() : List.of(); + } + + private static boolean sharesContentTag(Set first, Set second) { + if (!first.isEmpty() && !second.isEmpty()) { + for (String tag : first) { + if (second.contains(tag)) { + return true; + } + } + + return false; + } else { + return first.isEmpty() && second.isEmpty(); + } + } + + private static Set normalizeTags(Collection tags) { + if (tags != null && !tags.isEmpty()) { + LinkedHashSet normalized = new LinkedHashSet<>(); + + for (String raw : tags) { + if (raw != null) { + String tag = raw.trim().toLowerCase(Locale.ROOT); + if (!tag.isEmpty()) { + normalized.add(tag); + } + } + } + + return normalized.isEmpty() ? Set.of() : Set.copyOf(normalized); + } else { + return Set.of(); + } + } +} diff --git a/src/main/java/com/nonid/NonDestroyedSkinSystem.java b/src/main/java/com/nonid/NonDestroyedSkinSystem.java new file mode 100644 index 0000000..026e7b7 --- /dev/null +++ b/src/main/java/com/nonid/NonDestroyedSkinSystem.java @@ -0,0 +1,697 @@ +package com.nonid; + +import com.nonid.network.DestroyedSkinMaskSyncS2CPayload; +import com.nonid.network.DestroyedSkinMaskUploadC2SPayload; +import com.nonid.network.DestroyedSkinParticlesS2CPayload; +import com.nonid.network.DestroyedSkinStateS2CPayload; +import com.nonid.network.DestroyedSkinSyncS2CPayload; +import com.nonid.network.DestroyedSkinUploadC2SPayload; +import com.nonid.network.ManualDamageDestroyedSkinC2SPayload; +import com.nonid.network.RepairDestroyedSkinC2SPayload; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.registry.Registries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.sound.SoundEvents; + +final class NonDestroyedSkinSystem { + private static final int DESTROYED_SKIN_MAX_DAMAGE = 10; + private static final int DESTROYED_SKIN_STAGE_1_DAMAGE = 1; + private static final int DESTROYED_SKIN_STAGE_2_DAMAGE = 4; + private static final int DESTROYED_SKIN_STAGE_3_DAMAGE = 7; + private static final int DESTROYED_SKIN_STAGE_4_DAMAGE = 10; + private static final int DESTROYED_SKIN_REPAIR_COOLDOWN_TICKS = 7; + private static final int UPLOAD_COOLDOWN_TICKS = 20; + private static final Map DESTROYED_SKINS_BY_PLAYER = new ConcurrentHashMap<>(); + private static final Map DESTROYED_SKIN_MASKS_BY_PLAYER = new ConcurrentHashMap<>(); + private static final Map DESTROYED_SKIN_STAGE_BY_PLAYER = new ConcurrentHashMap<>(); + private static final Map DESTROYED_SKIN_REPAIR_COOLDOWNS = new ConcurrentHashMap<>(); + private static final Map NEXT_UPLOAD_TICK = new ConcurrentHashMap<>(); + + private NonDestroyedSkinSystem() { + } + + static void syncDestroyedSkins(ServerPlayerEntity player) { + if (player != null) { + MinecraftServer server = player.getEntityWorld().getServer(); + if (server != null) { + boolean enabled = NeedsOfNature.isDestroyedSkinSystemEnabled(); + + for (ServerPlayerEntity listedPlayer : server.getPlayerManager().getPlayerList()) { + UUID uuid = listedPlayer.getUuid(); + if (!enabled) { + sendDestroyedSkinStage(player, uuid, 0); + } else { + byte[] skin = DESTROYED_SKINS_BY_PLAYER.get(uuid); + if (skin != null && skin.length > 0) { + sendDestroyedSkin(player, uuid, getDestroyedSkinStage(uuid), skin); + } else { + sendDestroyedSkinStage(player, uuid, getDestroyedSkinStage(uuid)); + } + + byte[][] masks = DESTROYED_SKIN_MASKS_BY_PLAYER.get(uuid); + if (masks != null) { + for (int stage = 1; stage < Math.min(masks.length, 4); stage++) { + byte[] bytes = masks[stage]; + if (bytes != null && bytes.length > 0) { + sendDestroyedSkinMask(player, uuid, stage, bytes); + } + } + } + } + } + } + } + } + + static void registerDestroyedSkinNetworking() { + ServerPlayNetworking.registerGlobalReceiver( + DestroyedSkinUploadC2SPayload.ID, + (payload, context) -> context.server() + .execute( + () -> { + ServerPlayerEntity player = context.player(); + if (player != null) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + byte[] bytes = payload.pngBytes(); + UUID playerUuid = player.getUuid(); + if (acceptUpload(player, new NonDestroyedSkinSystem.UploadKey(playerUuid, 0))) { + if (!isValidDestroyedSkinPayload(bytes)) { + NeedsOfNature.LOGGER + .warn( + "[NoN] Ignoring invalid destroyed skin upload from {} ({} bytes).", + player.getName().getString(), + bytes == null ? 0 : bytes.length + ); + } else { + byte[] previous = DESTROYED_SKINS_BY_PLAYER.get(playerUuid); + if (!Arrays.equals(previous, bytes)) { + DESTROYED_SKINS_BY_PLAYER.put(playerUuid, (byte[])bytes.clone()); + NeedsOfNature.logRuntime( + "[NoN] Received destroyed skin upload from {} ({} bytes, stage {}).", + player.getName().getString(), + bytes.length, + getDestroyedSkinStage(playerUuid) + ); + broadcastDestroyedSkin(context.server(), playerUuid, getDestroyedSkinStage(playerUuid), bytes); + } + } + } + } + } + } + ) + ); + ServerPlayNetworking.registerGlobalReceiver( + DestroyedSkinMaskUploadC2SPayload.ID, + (payload, context) -> context.server() + .execute( + () -> { + ServerPlayerEntity player = context.player(); + if (player != null) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + int stage = payload.stage(); + byte[] bytes = payload.pngBytes(); + if (stage > 0 && stage < 4 && bytes != null) { + UUID playerUuid = player.getUuid(); + if (acceptUpload(player, new NonDestroyedSkinSystem.UploadKey(playerUuid, stage))) { + byte[][] existingMasks = DESTROYED_SKIN_MASKS_BY_PLAYER.get(playerUuid); + byte[] previous = existingMasks == null ? null : existingMasks[stage]; + if (!Arrays.equals(previous, bytes.length == 0 ? null : bytes)) { + if (bytes.length == 0) { + byte[][] masks = existingMasks; + if (masks != null && stage < masks.length) { + masks[stage] = null; + if (!hasAnyMask(masks)) { + DESTROYED_SKIN_MASKS_BY_PLAYER.remove(playerUuid); + } + } + + broadcastDestroyedSkinMask(context.server(), playerUuid, stage, bytes); + } else if (!isValidDestroyedSkinMaskPayload(bytes)) { + NeedsOfNature.LOGGER + .warn( + "[NoN] Ignoring invalid destroyed skin mask upload from {} (stage {}, {} bytes).", + new Object[]{player.getName().getString(), stage, bytes.length} + ); + } else { + byte[][] masks = DESTROYED_SKIN_MASKS_BY_PLAYER.computeIfAbsent(playerUuid, ignored -> new byte[4][]); + masks[stage] = (byte[])bytes.clone(); + NeedsOfNature.logRuntime( + "[NoN] Received destroyed skin mask upload from {} (stage {}, {} bytes).", + player.getName().getString(), + stage, + bytes.length + ); + broadcastDestroyedSkinMask(context.server(), playerUuid, stage, bytes); + } + } + } + } else { + NeedsOfNature.LOGGER + .warn( + "[NoN] Ignoring invalid destroyed skin mask upload from {} (stage {}, {} bytes).", + new Object[]{player.getName().getString(), stage, bytes == null ? 0 : bytes.length} + ); + } + } + } + } + ) + ); + } + + static void registerDestroyedSkinRepairNetworking() { + ServerPlayNetworking.registerGlobalReceiver( + RepairDestroyedSkinC2SPayload.ID, (payload, context) -> context.server().execute(() -> repairDestroyedSkin(context.player())) + ); + ServerPlayNetworking.registerGlobalReceiver( + ManualDamageDestroyedSkinC2SPayload.ID, (payload, context) -> context.server().execute(() -> manuallyDamageDestroyedSkin(context.player())) + ); + } + + private static void repairDestroyedSkin(ServerPlayerEntity player) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + if (player != null && player.isAlive() && player instanceof DestroyedSkinHolder holder) { + if (player.getEntityWorld() instanceof ServerWorld world) { + if (NonSkinRepairBlocks.isNearRepairBlock(world, player.getBlockPos(), NeedsOfNature.getConfig())) { + UUID uuid = player.getUuid(); + long now = world.getTime(); + long blockedUntil = DESTROYED_SKIN_REPAIR_COOLDOWNS.getOrDefault(uuid, 0L); + if (now >= blockedUntil) { + int beforeDamage = clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()); + if (beforeDamage <= 0) { + DESTROYED_SKIN_REPAIR_COOLDOWNS.remove(uuid); + } else { + int afterDamage = clampDestroyedSkinDamage(beforeDamage - 1); + holder.setDestroyedSkinDamage(afterDamage); + setDestroyedSkinStage(player, destroyedSkinStageForDamage(afterDamage)); + playDestroyedSkinRepairSound(world, player); + if (afterDamage > 0) { + DESTROYED_SKIN_REPAIR_COOLDOWNS.put(uuid, now + 7L); + } else { + DESTROYED_SKIN_REPAIR_COOLDOWNS.remove(uuid); + } + } + } + } + } + } + } + } + + private static void manuallyDamageDestroyedSkin(ServerPlayerEntity player) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + if (player != null && player.isAlive()) { + if (player.getEntityWorld() instanceof ServerWorld world) { + if (hasShearsForManualDestroyedSkinDamage(player)) { + int beforeDamage = player instanceof DestroyedSkinHolder holder ? clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()) : 10; + applyDestroyedSkinAttackDamage(world, player); + int afterDamage = player instanceof DestroyedSkinHolder holder ? clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()) : beforeDamage; + if (afterDamage > beforeDamage) { + playDestroyedSkinShearSound(world, player); + } + } + } + } + } + } + + private static boolean hasShearsForManualDestroyedSkinDamage(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + ItemStack cursorStack = player.currentScreenHandler != null ? player.currentScreenHandler.getCursorStack() : ItemStack.EMPTY; + return cursorStack.isOf(Items.SHEARS) || player.getMainHandStack().isOf(Items.SHEARS) || player.getOffHandStack().isOf(Items.SHEARS); + } + + private static boolean isValidDestroyedSkinPayload(byte[] bytes) { + if (bytes != null && bytes.length > 0 && bytes.length <= 1048576) { + int[] dimensions = readPngDimensions(bytes); + if (dimensions == null) { + return false; + } + + int width = dimensions[0]; + int height = dimensions[1]; + return width == height && width >= 64 && width <= 512 && width % 64 == 0 && hasValidPngChunkStructure(bytes); + } else { + return false; + } + } + + private static boolean isValidDestroyedSkinMaskPayload(byte[] bytes) { + if (bytes != null && bytes.length > 0 && bytes.length <= 65536) { + int[] dimensions = readPngDimensions(bytes); + return dimensions != null && dimensions[0] == 64 && dimensions[1] == 64 && hasValidPngChunkStructure(bytes); + } else { + return false; + } + } + + private static boolean acceptUpload(ServerPlayerEntity player, NonDestroyedSkinSystem.UploadKey key) { + long now = player.getEntityWorld().getTime(); + long next = NEXT_UPLOAD_TICK.getOrDefault(key, Long.MIN_VALUE); + if (now < next) { + return false; + } + + NEXT_UPLOAD_TICK.put(key, now + 20L); + return true; + } + + private static boolean hasValidPngChunkStructure(byte[] bytes) { + int offset = 8; + boolean sawIdat = false; + boolean first = true; + + while (offset + 12 <= bytes.length) { + long length = Integer.toUnsignedLong(readBigEndianInt(bytes, offset)); + if (length > bytes.length - offset - 12L) { + return false; + } + + int typeOffset = offset + 4; + boolean ihdr = chunkTypeEquals(bytes, typeOffset, 'I', 'H', 'D', 'R'); + boolean idat = chunkTypeEquals(bytes, typeOffset, 'I', 'D', 'A', 'T'); + boolean iend = chunkTypeEquals(bytes, typeOffset, 'I', 'E', 'N', 'D'); + if (first && (!ihdr || length != 13L)) { + return false; + } + + first = false; + sawIdat |= idat; + offset += Math.toIntExact(length + 12L); + if (iend) { + return length == 0L && sawIdat && offset == bytes.length; + } + } + + return false; + } + + private static boolean chunkTypeEquals(byte[] bytes, int offset, char a, char b, char c, char d) { + return offset >= 0 + && offset + 4 <= bytes.length + && bytes[offset] == (byte)a + && bytes[offset + 1] == (byte)b + && bytes[offset + 2] == (byte)c + && bytes[offset + 3] == (byte)d; + } + + static void clearPlayerRuntimeState(MinecraftServer server, UUID playerUuid) { + if (playerUuid != null) { + broadcastDestroyedSkin(server, playerUuid, 0, new byte[0]); + + for (int stage = 1; stage < 4; stage++) { + broadcastDestroyedSkinMask(server, playerUuid, stage, new byte[0]); + } + + broadcastDestroyedSkinStage(server, playerUuid, 0); + DESTROYED_SKINS_BY_PLAYER.remove(playerUuid); + DESTROYED_SKIN_MASKS_BY_PLAYER.remove(playerUuid); + DESTROYED_SKIN_STAGE_BY_PLAYER.remove(playerUuid); + DESTROYED_SKIN_REPAIR_COOLDOWNS.remove(playerUuid); + NEXT_UPLOAD_TICK.keySet().removeIf(key -> playerUuid.equals(key.playerUuid())); + } + } + + static void clearRuntimeState() { + DESTROYED_SKINS_BY_PLAYER.clear(); + DESTROYED_SKIN_MASKS_BY_PLAYER.clear(); + DESTROYED_SKIN_STAGE_BY_PLAYER.clear(); + DESTROYED_SKIN_REPAIR_COOLDOWNS.clear(); + NEXT_UPLOAD_TICK.clear(); + } + + private static boolean hasAnyMask(byte[][] masks) { + if (masks == null) { + return false; + } + + for (int stage = 1; stage < masks.length; stage++) { + if (masks[stage] != null && masks[stage].length > 0) { + return true; + } + } + + return false; + } + + private static int[] readPngDimensions(byte[] bytes) { + if (bytes == null || bytes.length < 24) { + return null; + } else if ((bytes[0] & 255) == 137 + && bytes[1] == 80 + && bytes[2] == 78 + && bytes[3] == 71 + && bytes[4] == 13 + && bytes[5] == 10 + && bytes[6] == 26 + && bytes[7] == 10 + && bytes[8] == 0 + && bytes[9] == 0 + && bytes[10] == 0 + && bytes[11] == 13 + && bytes[12] == 73 + && bytes[13] == 72 + && bytes[14] == 68 + && bytes[15] == 82) { + int width = readBigEndianInt(bytes, 16); + int height = readBigEndianInt(bytes, 20); + return width > 0 && height > 0 ? new int[]{width, height} : null; + } else { + return null; + } + } + + private static int readBigEndianInt(byte[] bytes, int offset) { + return (bytes[offset] & 0xFF) << 24 | (bytes[offset + 1] & 0xFF) << 16 | (bytes[offset + 2] & 0xFF) << 8 | bytes[offset + 3] & 0xFF; + } + + private static int getDestroyedSkinStage(UUID playerUuid) { + return playerUuid == null ? 0 : Math.max(0, Math.min(4, DESTROYED_SKIN_STAGE_BY_PLAYER.getOrDefault(playerUuid, 0))); + } + + static int getDestroyedSkinStageForPlayer(ServerPlayerEntity player) { + if (player == null) { + return 0; + } else if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + return 0; + } else { + return player instanceof DestroyedSkinHolder holder + ? Math.max(0, Math.min(4, holder.getDestroyedSkinStage())) + : getDestroyedSkinStage(player.getUuid()); + } + } + + static void broadcastDestroyedSkinStage(ServerPlayerEntity player) { + if (player != null) { + MinecraftServer server = player.getEntityWorld().getServer(); + if (server != null) { + broadcastDestroyedSkinStage(server, player.getUuid(), getDestroyedSkinStageForPlayer(player)); + } + } + } + + static void syncDestroyedSkinStageToOtherPlayers(ServerPlayerEntity player) { + if (player != null) { + MinecraftServer server = player.getEntityWorld().getServer(); + if (server != null) { + int stage = getDestroyedSkinStageForPlayer(player); + + for (ServerPlayerEntity target : server.getPlayerManager().getPlayerList()) { + if (!target.getUuid().equals(player.getUuid())) { + sendDestroyedSkinStage(target, player.getUuid(), stage); + } + } + } + } + } + + static void cacheDestroyedSkinStage(ServerPlayerEntity player) { + if (player != null) { + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + DESTROYED_SKIN_STAGE_BY_PLAYER.remove(player.getUuid()); + } else if (player instanceof DestroyedSkinHolder holder) { + int damage = clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()); + int stage = damage > 0 ? destroyedSkinStageForDamage(damage) : Math.max(0, Math.min(4, holder.getDestroyedSkinStage())); + if (stage == 0) { + DESTROYED_SKIN_STAGE_BY_PLAYER.remove(player.getUuid()); + } else { + DESTROYED_SKIN_STAGE_BY_PLAYER.put(player.getUuid(), stage); + } + } + } + } + + static void setDestroyedSkinStage(ServerPlayerEntity player, int stage) { + if (player != null) { + UUID uuid = player.getUuid(); + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + clearDestroyedSkinStoredState(player); + if (player.getEntityWorld() instanceof ServerWorld world) { + broadcastDestroyedSkinStage(world.getServer(), uuid, 0); + } + } else { + int clamped = Math.max(0, Math.min(4, stage)); + if (player instanceof DestroyedSkinHolder holder) { + holder.setDestroyedSkinStage(clamped); + } + + if (clamped == 0) { + DESTROYED_SKIN_STAGE_BY_PLAYER.remove(uuid); + } else { + DESTROYED_SKIN_STAGE_BY_PLAYER.put(uuid, clamped); + } + + if (player.getEntityWorld() instanceof ServerWorld world) { + broadcastDestroyedSkinStage(world.getServer(), uuid, clamped); + } + + NonApiInternals.fireDestroyedSkinChanged(player); + NeedsOfNature.checkBadIdeaBeaconAdvancement(player); + } + } + } + + static void applyDestroyedSkinAttackDamage(ServerWorld world, ServerPlayerEntity player) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + if (world != null && player != null && player instanceof DestroyedSkinHolder holder) { + int damageGain = rollDestroyedSkinDamage(world, player); + applyDestroyedSkinDamage(world, player, holder, damageGain); + } + } + } + + static void applyDestroyedSkinVoluntaryJoinInitialDamage(ServerWorld world, ServerPlayerEntity player) { + if (NeedsOfNature.isDestroyedSkinSystemEnabled()) { + if (world != null && player != null && player instanceof DestroyedSkinHolder holder) { + int beforeDamage = clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()); + if (beforeDamage == 0) { + applyDestroyedSkinDamage(world, player, holder, 1); + } + } + } + } + + private static void applyDestroyedSkinDamage(ServerWorld world, ServerPlayerEntity player, DestroyedSkinHolder holder, int damageGain) { + if (world != null && player != null && holder != null && damageGain > 0) { + int beforeDamage = clampDestroyedSkinDamage(holder.getDestroyedSkinDamage()); + int afterDamage = clampDestroyedSkinDamage(beforeDamage + damageGain); + if (afterDamage != beforeDamage) { + holder.setDestroyedSkinDamage(afterDamage); + int beforeStage = destroyedSkinStageForDamage(beforeDamage); + int afterStage = destroyedSkinStageForDamage(afterDamage); + setDestroyedSkinStage(player, afterStage); + playDestroyedSkinRipSound(world, player); + broadcastDestroyedSkinRipParticles(world, player); + if (afterStage != beforeStage) { + NeedsOfNature.logRuntime("[NoN] {} destroyed skin advanced to stage {} ({}/{}).", player.getName().getString(), afterStage, afterDamage, 10); + } + } + } + } + + private static int rollDestroyedSkinDamage(ServerWorld world, ServerPlayerEntity player) { + double multiplier = NonTrinketsIntegration.getAccessoryEffects(player).destroyedSkinDamageMultiplier(); + if (Double.isFinite(multiplier) && !(multiplier <= 0.0)) { + int whole = (int)Math.floor(multiplier); + double fractional = multiplier - whole; + if (fractional > 0.0 && world.getRandom().nextDouble() < fractional) { + whole++; + } + + return Math.max(0, whole); + } else { + return 0; + } + } + + static void resetDestroyedSkinDamage(ServerPlayerEntity player) { + if (player != null) { + if (player instanceof DestroyedSkinHolder holder) { + holder.setDestroyedSkinDamage(0); + } + + setDestroyedSkinStage(player, 0); + } + } + + static void clearDestroyedSkinStoredState(ServerPlayerEntity player) { + if (player != null) { + UUID uuid = player.getUuid(); + if (player instanceof DestroyedSkinHolder holder) { + holder.setDestroyedSkinDamage(0); + holder.setDestroyedSkinStage(0); + } + + DESTROYED_SKIN_STAGE_BY_PLAYER.remove(uuid); + DESTROYED_SKIN_REPAIR_COOLDOWNS.remove(uuid); + } + } + + private static int clampDestroyedSkinDamage(int damage) { + return Math.max(0, Math.min(10, damage)); + } + + private static int destroyedSkinStageForDamage(int damage) { + int clamped = clampDestroyedSkinDamage(damage); + if (clamped >= 10) { + return 4; + } else if (clamped >= 7) { + return 3; + } else if (clamped >= 4) { + return 2; + } else { + return clamped >= 1 ? 1 : 0; + } + } + + static int destroyedSkinDamageForStage(int stage) { + return switch (Math.max(0, Math.min(4, stage))) { + case 1 -> 1; + case 2 -> 4; + case 3 -> 7; + case 4 -> 10; + default -> 0; + }; + } + + private static void playDestroyedSkinRipSound(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + int index = 1 + world.getRandom().nextInt(3); + String soundPath = "rip" + String.format(Locale.ROOT, "%02d", index); + float pitch = 0.94F + world.getRandom().nextFloat() * 0.12F; + world.playSound( + null, + player.getX(), + player.getY() + player.getStandingEyeHeight() * 0.5, + player.getZ(), + (SoundEvent)Registries.SOUND_EVENT.get(NeedsOfNature.id(soundPath)), + SoundCategory.PLAYERS, + 0.75F, + pitch + ); + } + } + + private static void playDestroyedSkinShearSound(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + world.playSound( + null, + player.getX(), + player.getY() + player.getStandingEyeHeight() * 0.5, + player.getZ(), + SoundEvents.ENTITY_SHEEP_SHEAR, + SoundCategory.PLAYERS, + 0.8F, + 0.95F + world.getRandom().nextFloat() * 0.1F + ); + } + } + + private static void playDestroyedSkinRepairSound(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + world.playSound( + null, + player.getX(), + player.getY() + player.getStandingEyeHeight() * 0.5, + player.getZ(), + SoundEvents.BLOCK_WOOL_PLACE, + SoundCategory.PLAYERS, + 1.35F, + 1.15F + world.getRandom().nextFloat() * 0.15F + ); + } + } + + private static void broadcastDestroyedSkinRipParticles(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + int count = 3 + world.getRandom().nextInt(4); + long seed = world.getRandom().nextLong(); + double x = player.getX(); + double y = player.getY() + player.getHeight() * 0.55; + double z = player.getZ(); + DestroyedSkinParticlesS2CPayload payload = new DestroyedSkinParticlesS2CPayload(player.getUuid(), x, y, z, count, seed); + + for (ServerPlayerEntity target : world.getPlayers()) { + if (!(target.squaredDistanceTo(player) > 9216.0) && ServerPlayNetworking.canSend(target, DestroyedSkinParticlesS2CPayload.ID)) { + ServerPlayNetworking.send(target, payload); + } + } + } + } + + private static void broadcastDestroyedSkin(MinecraftServer server, UUID playerUuid, int stage, byte[] bytes) { + if (server != null && playerUuid != null && bytes != null) { + for (ServerPlayerEntity target : server.getPlayerManager().getPlayerList()) { + sendDestroyedSkin(target, playerUuid, stage, bytes); + } + } + } + + private static void broadcastDestroyedSkinMask(MinecraftServer server, UUID playerUuid, int stage, byte[] bytes) { + if (server != null && playerUuid != null && bytes != null) { + for (ServerPlayerEntity target : server.getPlayerManager().getPlayerList()) { + sendDestroyedSkinMask(target, playerUuid, stage, bytes); + } + } + } + + private static void broadcastDestroyedSkinStage(MinecraftServer server, UUID playerUuid, int stage) { + if (server != null && playerUuid != null) { + for (ServerPlayerEntity target : server.getPlayerManager().getPlayerList()) { + sendDestroyedSkinStage(target, playerUuid, stage); + } + } + } + + private static void sendDestroyedSkin(ServerPlayerEntity target, UUID playerUuid, int stage, byte[] bytes) { + if (target != null && playerUuid != null && bytes != null) { + if (ServerPlayNetworking.canSend(target, DestroyedSkinSyncS2CPayload.ID)) { + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + stage = 0; + } + + ServerPlayNetworking.send(target, new DestroyedSkinSyncS2CPayload(playerUuid, stage, bytes)); + } + } + } + + private static void sendDestroyedSkinMask(ServerPlayerEntity target, UUID playerUuid, int stage, byte[] bytes) { + if (target != null && playerUuid != null && bytes != null) { + if (stage > 0 && stage < 4) { + if (ServerPlayNetworking.canSend(target, DestroyedSkinMaskSyncS2CPayload.ID)) { + ServerPlayNetworking.send(target, new DestroyedSkinMaskSyncS2CPayload(playerUuid, stage, bytes)); + } + } + } + } + + private static void sendDestroyedSkinStage(ServerPlayerEntity target, UUID playerUuid, int stage) { + if (target != null && playerUuid != null) { + if (ServerPlayNetworking.canSend(target, DestroyedSkinStateS2CPayload.ID)) { + if (!NeedsOfNature.isDestroyedSkinSystemEnabled()) { + stage = 0; + } + + ServerPlayNetworking.send(target, new DestroyedSkinStateS2CPayload(playerUuid, stage)); + } + } + } + + private record UploadKey(UUID playerUuid, int maskStage) { + } +} diff --git a/src/main/java/com/nonid/NonDiagnostics.java b/src/main/java/com/nonid/NonDiagnostics.java new file mode 100644 index 0000000..a6ef81c --- /dev/null +++ b/src/main/java/com/nonid/NonDiagnostics.java @@ -0,0 +1,166 @@ +package com.nonid; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStopped; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Disconnect; +import net.minecraft.entity.Entity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.jetbrains.annotations.Nullable; + +public final class NonDiagnostics { + private static final boolean RUNTIME_LOGS = Boolean.getBoolean("needsofnature.runtimeLogs"); + private static final int MAX_PENDING_SETUP_CHAT_MESSAGES = 100; + private static final Map DEBUG_CHAT_PREFERENCES = new ConcurrentHashMap<>(); + private static final List PENDING_SETUP_CHAT = new ArrayList<>(); + private static boolean initialized; + + private NonDiagnostics() { + } + + public static synchronized void initialize() { + if (!initialized) { + initialized = true; + ServerTickEvents.END_SERVER_TICK.register(NonDiagnostics::flushPendingSetupChat); + ServerPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, server) -> DEBUG_CHAT_PREFERENCES.remove(handler.player.getUuid())); + ServerLifecycleEvents.SERVER_STOPPED.register((ServerStopped)server -> { + DEBUG_CHAT_PREFERENCES.clear(); + synchronized (PENDING_SETUP_CHAT) { + PENDING_SETUP_CHAT.clear(); + } + }); + } + } + + public static void setDebugChatPreference(UUID playerUuid, @Nullable NonDebugChatMode mode) { + if (playerUuid != null) { + DEBUG_CHAT_PREFERENCES.put(playerUuid, mode == null ? NonDebugChatMode.SETUP_ERRORS : mode); + } + } + + public static boolean shouldSendDebugChat(@Nullable ServerPlayerEntity player, @Nullable NonDebugChatCategory category) { + if (player == null) { + return false; + } + + NonDebugChatMode mode = DEBUG_CHAT_PREFERENCES.getOrDefault(player.getUuid(), NonDebugChatMode.SETUP_ERRORS); + return mode.allows(category == null ? NonDebugChatCategory.INFO : category); + } + + public static boolean shouldSendDebugChat(@Nullable ServerPlayerEntity player, @Nullable NonDebugChatCategory category, boolean force) { + return force || shouldSendDebugChat(player, category); + } + + public static void sendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, Text message, boolean force) { + if (message != null && shouldSendDebugChat(player, category, force)) { + player.sendMessage(message.copy().formatted(chatColor(category)), false); + } + } + + public static void logSetupWarning(String template, Object... args) { + NeedsOfNature.LOGGER.warn(template, args); + queueSetupChat(formatLogTemplate(template, args)); + } + + public static void logSetupError(String template, Object... args) { + NeedsOfNature.LOGGER.error(template, args); + queueSetupChat(formatLogTemplate(template, args)); + } + + public static void logRuntime(String template, Object... args) { + if (RUNTIME_LOGS) { + NeedsOfNature.LOGGER.info(template, args); + } else { + NeedsOfNature.LOGGER.debug(template, args); + } + } + + public static void queueSetupChat(String message) { + if (message != null && !message.isBlank()) { + synchronized (PENDING_SETUP_CHAT) { + if (PENDING_SETUP_CHAT.size() >= 100) { + PENDING_SETUP_CHAT.removeFirst(); + } + + PENDING_SETUP_CHAT.add(new NonDiagnostics.PendingSetupChatMessage(Text.literal(message), new HashSet<>())); + } + } + } + + public static String formatLogTemplate(String template, Object... args) { + if (template == null) { + return ""; + } + + if (args != null && args.length != 0) { + StringBuilder out = new StringBuilder(template.length() + args.length * 8); + int argIndex = 0; + + for (int i = 0; i < template.length(); i++) { + char c = template.charAt(i); + if (c == '{' && i + 1 < template.length() && template.charAt(i + 1) == '}' && argIndex < args.length) { + Object arg = args[argIndex++]; + if (!(arg instanceof Throwable)) { + out.append(String.valueOf(arg)); + } + + i++; + } else { + out.append(c); + } + } + + return out.toString(); + } else { + return template; + } + } + + public static Formatting chatColor(@Nullable NonDebugChatCategory category) { + if (category == null) { + return Formatting.WHITE; + } + + return switch (category) { + case ALWAYS -> Formatting.DARK_GRAY; + case SETUP -> Formatting.YELLOW; + case WARNING -> Formatting.LIGHT_PURPLE; + case ERROR -> Formatting.RED; + case INFO -> Formatting.WHITE; + }; + } + + private static void flushPendingSetupChat(MinecraftServer server) { + if (server != null) { + List players = server.getPlayerManager().getPlayerList(); + if (!players.isEmpty()) { + synchronized (PENDING_SETUP_CHAT) { + PENDING_SETUP_CHAT.removeIf(message -> { + for (ServerPlayerEntity player : players) { + if (player != null && !message.sentTo().contains(player.getUuid())) { + sendDebugChat(player, NonDebugChatCategory.SETUP, message.message(), false); + message.sentTo().add(player.getUuid()); + } + } + + return message.sentTo().containsAll(players.stream().map(Entity::getUuid).toList()); + }); + } + } + } + } + + private record PendingSetupChatMessage(Text message, Set sentTo) { + } +} diff --git a/src/main/java/com/nonid/NonGatherCancelReason.java b/src/main/java/com/nonid/NonGatherCancelReason.java new file mode 100644 index 0000000..adc4d90 --- /dev/null +++ b/src/main/java/com/nonid/NonGatherCancelReason.java @@ -0,0 +1,7 @@ +package com.nonid; + +public enum NonGatherCancelReason { + NO_LOS, + TOO_FAR, + TIMEOUT; +} diff --git a/src/main/java/com/nonid/NonGatherSystem.java b/src/main/java/com/nonid/NonGatherSystem.java new file mode 100644 index 0000000..b50de18 --- /dev/null +++ b/src/main/java/com/nonid/NonGatherSystem.java @@ -0,0 +1,772 @@ +package com.nonid; + +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AnimationStageInfo; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryKey; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Box; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +public final class NonGatherSystem { + private static final double MOVE_SPEED = 1.15; + private static final int REPATH_EVERY_TICKS = 5; + private static final int TIMEOUT_TICKS = 3600; + private static final int NO_LOS_TIMEOUT_TICKS = 100; + private static final int TOO_FAR_TIMEOUT_TICKS = 200; + private static final double TIMEOUT_MAX_DIST_SQ = 100.0; + private static final double GATHER_EXTRA_DIST = 0.25; + private static final double GATHER_TARGET_OVERSHOOT = 1.0; + private static final double START_MAX_VERTICAL_DELTA = 2.5; + private static final float LOOK_YAW_SPEED = 30.0F; + private static final float LOOK_PITCH_SPEED = 30.0F; + private static final Map SESSIONS_BY_INITIATOR = new ConcurrentHashMap<>(); + + private NonGatherSystem() { + } + + static void clearRuntimeState() { + SESSIONS_BY_INITIATOR.clear(); + } + + @Nullable + public static NonGatherSystem.GatherCandidate findGatherCandidate( + ServerWorld world, List actors, boolean isAttack, Entity attacker, @Nullable Entity gatherAnchor + ) { + if (world != null && actors != null && !actors.isEmpty() && attacker != null) { + List sortedActors = new ArrayList<>(actors); + sortedActors.sort(Comparator.comparingInt(Entity::getId)); + AnimationService.MatchedAnimation matched = isAttack + ? NonAnimationMatcher.findAutomaticAttack(world, sortedActors, Set.of(), gatherAnchor, true, attacker, Set.of()) + : NonAnimationMatcher.findAutomatic(world, sortedActors, Set.of(), gatherAnchor, true, null, Set.of()); + return toGatherCandidate(matched); + } else { + return null; + } + } + + @Nullable + public static NonGatherSystem.GatherCandidate findUnprotectedGatherCandidate( + ServerWorld world, List actors, boolean isAttack, Entity attacker, @Nullable Entity gatherAnchor + ) { + NonGatherSystem.GatherCandidate candidate = findGatherCandidate(world, actors, isAttack, attacker, gatherAnchor); + if (candidate == null) { + return null; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(candidate.animationId()); + if (definition == null) { + return null; + } + + List sortedActors = new ArrayList<>(actors); + sortedActors.sort(Comparator.comparingInt(Entity::getId)); + return !NeedsOfNature.isProtectedByAccessory(world, definition, sortedActors) + ? candidate + : findUnprotectedFallbackCandidate(world, sortedActors, isAttack, attacker, gatherAnchor, true, candidate.animationId(), null); + } + + @Nullable + static NonGatherSystem.GatherCandidate findUnprotectedFallbackCandidate( + ServerWorld world, + List actors, + boolean isAttack, + Entity attacker, + @Nullable Entity gatherAnchor, + boolean requireStartEligibility, + Identifier blockedAnimationId, + @Nullable Identifier joinPredecessorAnimationId + ) { + if (blockedAnimationId == null) { + return null; + } + + List sortedActors = new ArrayList<>(actors); + sortedActors.sort(Comparator.comparingInt(Entity::getId)); + Set excluded = new HashSet<>(); + excluded.add(blockedAnimationId); + + while (true) { + AnimationService.MatchedAnimation matched = isAttack + ? NonAnimationMatcher.findAutomaticAttack( + world, sortedActors, Set.of(), gatherAnchor, requireStartEligibility, attacker, excluded, null, joinPredecessorAnimationId + ) + : NonAnimationMatcher.findAutomatic( + world, sortedActors, Set.of(), gatherAnchor, requireStartEligibility, null, excluded, null, joinPredecessorAnimationId + ); + NonGatherSystem.GatherCandidate candidate = toGatherCandidate(matched); + if (candidate == null) { + return null; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(candidate.animationId()); + if (definition == null) { + return null; + } + + if (!NeedsOfNature.isProtectedByAccessory(world, definition, sortedActors)) { + return candidate; + } + + excluded.add(candidate.animationId()); + } + } + + @Nullable + private static NonGatherSystem.GatherCandidate toGatherCandidate(@Nullable AnimationService.MatchedAnimation matched) { + return matched == null ? null : new NonGatherSystem.GatherCandidate(matched.animationId(), matched.actorKeys(), matched.stages()); + } + + public static boolean startMobPlayerGather( + ServerWorld world, MobEntity mob, ServerPlayerEntity player, Identifier animationId, List actorKeys, List stages + ) { + if (world != null && mob != null && player != null && animationId != null) { + if (mob.getEntityWorld() != world || player.getEntityWorld() != world) { + return false; + } else if (!NonMobGatherEligibility.canInitiateAutoGather(mob)) { + return false; + } else if (isAutomaticMobPlayerStartBlockedByMount(player)) { + reportAutomaticMobPlayerMountBlock(world, mob, player, animationId, "gather_start"); + return false; + } else { + NonGatherSystem.Session session = new NonGatherSystem.Session( + NonGatherSystem.SessionType.MOB_PLAYER, + mob.getUuid(), + player.getUuid(), + animationId, + List.copyOf(actorKeys), + stages == null ? List.of() : List.copyOf(stages), + true, + null, + world.getRegistryKey(), + world.getTime(), + 0, + 0, + 0 + ); + SESSIONS_BY_INITIATOR.put(mob.getUuid(), session); + NeedsOfNature.trackNoNGatherPair(world, List.of(mob, player)); + return true; + } + } else { + return false; + } + } + + public static boolean isAutomaticMobPlayerStartBlockedByMount(ServerPlayerEntity player) { + if (player != null && player.hasVehicle()) { + Entity vehicle = player.getVehicle(); + if (vehicle != null && !vehicle.isRemoved()) { + NonConfig config = NeedsOfNature.getConfig(); + if (config == null) { + return false; + } else { + return vehicle instanceof LivingEntity ? config.blockAnimationsWhileRidingLivingEntities() : config.blockAnimationsWhileRidingVehicles(); + } + } else { + return false; + } + } else { + return false; + } + } + + public static void reportAutomaticMobPlayerMountBlock( + ServerWorld world, MobEntity mob, ServerPlayerEntity player, @Nullable Identifier animationId, String phase + ) { + if (world != null && mob != null && player != null) { + Entity vehicle = player.getVehicle(); + Identifier mobType = Registries.ENTITY_TYPE.getId(mob.getType()); + Identifier vehicleType = vehicle == null ? null : Registries.ENTITY_TYPE.getId(vehicle.getType()); + NeedsOfNature.logRuntime( + "NoN automatic mob-player start blocked by mounted-player protection: phase={} mobType={} player={} vehicle={} anim={}", + phase, + mobType, + player.getName().getString(), + vehicleType, + animationId + ); + Text vehicleName = (Text)(vehicle == null ? Text.literal("载具") : vehicle.getDisplayName()); + NeedsOfNature.sendDebugChat( + player, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.mounted_animation_blocked", new Object[]{vehicleName}) + ); + } + } + + public static void startMobMobGather( + ServerWorld world, + MobEntity initiator, + MobEntity partner, + Identifier animationId, + List actorKeys, + List stages, + boolean attackMode + ) { + if (world != null && initiator != null && partner != null && animationId != null) { + if (initiator.getEntityWorld() == world && partner.getEntityWorld() == world) { + if (NonMobGatherEligibility.canInitiateAutoGather(initiator) && NonMobGatherEligibility.canBeAutoGatherTarget(partner)) { + NonGatherSystem.Session session = new NonGatherSystem.Session( + NonGatherSystem.SessionType.MOB_MOB, + initiator.getUuid(), + partner.getUuid(), + animationId, + List.copyOf(actorKeys), + stages == null ? List.of() : List.copyOf(stages), + attackMode, + null, + world.getRegistryKey(), + world.getTime(), + 0, + 0, + 0 + ); + SESSIONS_BY_INITIATOR.put(initiator.getUuid(), session); + NeedsOfNature.trackNoNGatherPair(world, List.of(initiator, partner)); + } + } + } + } + + public static void startJoinGather(ServerWorld world, MobEntity joiningMob, ServerPlayerEntity requester, UUID activeInstanceId, Identifier animationId) { + if (world != null && joiningMob != null && requester != null && activeInstanceId != null) { + if (joiningMob.getEntityWorld() == world && requester.getEntityWorld() == world) { + if (NonMobGatherEligibility.canInitiateAutoGather(joiningMob)) { + NonGatherSystem.Session session = new NonGatherSystem.Session( + NonGatherSystem.SessionType.MOB_JOIN_ACTIVE_ANIMATION, + joiningMob.getUuid(), + requester.getUuid(), + animationId, + List.of(), + List.of(), + false, + activeInstanceId, + world.getRegistryKey(), + world.getTime(), + 0, + 0, + 0 + ); + SESSIONS_BY_INITIATOR.put(joiningMob.getUuid(), session); + NeedsOfNature.trackNoNGatherPair(world, List.of(joiningMob, requester)); + } + } + } + } + + public static void tickServerWorld(ServerWorld world) { + if (world != null && !SESSIONS_BY_INITIATOR.isEmpty()) { + for (NonGatherSystem.Session session : List.copyOf(SESSIONS_BY_INITIATOR.values())) { + if (session != null && world.getRegistryKey().equals(session.worldKey())) { + if (!(world.getEntity(session.initiatorUuid()) instanceof MobEntity mob && mob.getEntityWorld() == world)) { + remove(session, world, false, null, "initiator_missing"); + } else if (session.type() == NonGatherSystem.SessionType.MOB_PLAYER) { + tickMobPlayer(world, session, mob); + } else if (session.type() == NonGatherSystem.SessionType.MOB_MOB) { + tickMobMob(world, session, mob); + } else { + tickJoin(world, session, mob); + } + } + } + } + } + + public static boolean isActorGathering(UUID actorUuid) { + if (actorUuid != null && !SESSIONS_BY_INITIATOR.isEmpty()) { + for (NonGatherSystem.Session session : SESSIONS_BY_INITIATOR.values()) { + if (session != null && (actorUuid.equals(session.initiatorUuid()) || actorUuid.equals(session.targetUuid()))) { + return true; + } + } + + return false; + } else { + return false; + } + } + + public static boolean isMobGatheringWithPlayer(ServerWorld world, UUID mobUuid, UUID playerUuid) { + if (world != null && mobUuid != null && playerUuid != null) { + NonGatherSystem.Session session = SESSIONS_BY_INITIATOR.get(mobUuid); + if (session == null || !world.getRegistryKey().equals(session.worldKey())) { + return false; + } else { + return !playerUuid.equals(session.targetUuid()) + ? false + : session.type() == NonGatherSystem.SessionType.MOB_PLAYER || session.type() == NonGatherSystem.SessionType.MOB_JOIN_ACTIVE_ANIMATION; + } + } else { + return false; + } + } + + public static void cancelForActors(ServerWorld world, Collection actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty() && !SESSIONS_BY_INITIATOR.isEmpty()) { + Set actors = new HashSet<>(actorUuids); + + for (NonGatherSystem.Session session : List.copyOf(SESSIONS_BY_INITIATOR.values())) { + if (session != null + && world.getRegistryKey().equals(session.worldKey()) + && (actors.contains(session.initiatorUuid()) || actors.contains(session.targetUuid()))) { + remove(session, world, false, null, "player_request_accepted"); + } + } + } + } + + private static void tickMobPlayer(ServerWorld world, NonGatherSystem.Session session, MobEntity mob) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(session.targetUuid()); + if (player == null || player.getEntityWorld() != world || !player.isAlive() || player.isRemoved() || mob.isRemoved()) { + remove(session, world, false, null, "invalid_actor"); + } else if (!NonMobGatherEligibility.canInitiateAutoGather(mob)) { + remove(session, world, false, null, "mob_not_auto_gatherable"); + } else if (!NeedsOfNature.canMobAttackPlayer(player) || NeedsOfNature.isMobAttackFailsafeActive(world, mob.getUuid())) { + remove(session, world, false, null, "player_not_attackable_or_failsafe"); + } else if (isAutomaticMobPlayerStartBlockedByMount(player)) { + reportAutomaticMobPlayerMountBlock(world, mob, player, session.animationId(), "close_range_start"); + remove(session, world, false, null, "player_mounted_protected"); + } else if (AnimationService.isActorPendingOrActive(world, mob.getUuid()) || AnimationService.isActorPendingOrActive(world, player.getUuid())) { + remove(session, world, false, null, "afw_actor_pending_or_active"); + } else if (timedOut(world, session)) { + remove(session, world, true, NonGatherCancelReason.TIMEOUT, "timeout"); + } else { + int noLosTicks = mob.canSee(player) ? 0 : session.noLosTicks() + 1; + int tooFarTicks = mob.squaredDistanceTo(player) > 100.0 ? session.tooFarTicks() + 1 : 0; + if (!cancelledForDistance(session, world, noLosTicks, tooFarTicks)) { + mob.getLookControl().lookAt(player, 30.0F, 30.0F); + if (NonAttackSystem.hasAnimationStartProtection(world, player)) { + int repathCooldown = Math.max(0, session.repathCooldown() - 1); + if (repathCooldown <= 0) { + startMovingIntoTarget(mob, player); + repathCooldown = 5; + } + + update(session, repathCooldown, noLosTicks, tooFarTicks); + } else if (closeEnoughToStart(mob, player)) { + List actors = sortedActors(mob, player); + NonGatherSystem.StartCandidate startCandidate = resolveProtectedStartCandidate(world, session, actors, true, mob, player); + if (startCandidate == null) { + NeedsOfNature.clearNoNGatherPair(world, List.of(mob.getUuid(), player.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + } else { + UUID instanceId = NeedsOfNature.startAnimationNowWithContext( + world, + startCandidate.animationId(), + actors, + startCandidate.actorKeys(), + startCandidate.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + player, + NeedsOfNature.buildModeContext(true) + ); + NeedsOfNature.clearNoNGatherPair(world, List.of(mob.getUuid(), player.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + if (instanceId == null) { + NeedsOfNature.logRuntime( + "NoN gather start failed at close range: mobType={} player={} anim={}", + Registries.ENTITY_TYPE.getId(mob.getType()), + player.getName().getString(), + startCandidate.animationId() + ); + } + } + } else { + int repathCooldown = Math.max(0, session.repathCooldown() - 1); + if (repathCooldown <= 0) { + startMovingIntoTarget(mob, player); + repathCooldown = 5; + } + + update(session, repathCooldown, noLosTicks, tooFarTicks); + } + } + } + } + + private static void tickMobMob(ServerWorld world, NonGatherSystem.Session session, MobEntity initiator) { + if (!( + world.getEntity(session.targetUuid()) instanceof MobEntity partner + && partner.getEntityWorld() == world + && partner.isAlive() + && !partner.isRemoved() + && !initiator.isRemoved() + )) { + remove(session, world, false, null, "invalid_actor"); + } else if (!NonMobGatherEligibility.canInitiateAutoGather(initiator) || !NonMobGatherEligibility.canBeAutoGatherTarget(partner)) { + remove(session, world, false, null, "mob_not_auto_gatherable"); + } else if (AnimationService.isActorPendingOrActive(world, initiator.getUuid()) || AnimationService.isActorPendingOrActive(world, partner.getUuid())) { + remove(session, world, false, null, "afw_actor_pending_or_active"); + } else if (timedOut(world, session)) { + remove(session, world, true, NonGatherCancelReason.TIMEOUT, "timeout"); + } else { + boolean hasLos = initiator.canSee(partner) && partner.canSee(initiator); + int noLosTicks = hasLos ? 0 : session.noLosTicks() + 1; + int tooFarTicks = initiator.squaredDistanceTo(partner) > 100.0 ? session.tooFarTicks() + 1 : 0; + if (!cancelledForDistance(session, world, noLosTicks, tooFarTicks)) { + initiator.getLookControl().lookAt(partner, 30.0F, 30.0F); + partner.getLookControl().lookAt(initiator, 30.0F, 30.0F); + if (closeEnoughToStart(initiator, partner)) { + List actors = sortedActors(initiator, partner); + NonGatherSystem.StartCandidate startCandidate = resolveProtectedStartCandidate( + world, session, actors, session.attackMode(), initiator, session.attackMode() ? partner : null + ); + if (startCandidate == null) { + NeedsOfNature.clearNoNGatherPair(world, List.of(initiator.getUuid(), partner.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + } else { + UUID instanceId = NeedsOfNature.startAnimationNowWithContext( + world, + startCandidate.animationId(), + actors, + startCandidate.actorKeys(), + startCandidate.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + session.attackMode() ? partner : null, + NeedsOfNature.buildModeContext(session.attackMode()) + ); + NeedsOfNature.clearNoNGatherPair(world, List.of(initiator.getUuid(), partner.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + if (instanceId == null) { + NeedsOfNature.logRuntime( + "NoN mob gather start failed at close range: first={} second={} anim={}", + Registries.ENTITY_TYPE.getId(initiator.getType()), + Registries.ENTITY_TYPE.getId(partner.getType()), + startCandidate.animationId() + ); + } + } + } else { + int repathCooldown = Math.max(0, session.repathCooldown() - 1); + if (repathCooldown <= 0) { + startMovingIntoTarget(initiator, partner); + startMovingIntoTarget(partner, initiator); + repathCooldown = 5; + } + + update(session, repathCooldown, noLosTicks, tooFarTicks); + } + } + } + } + + private static void tickJoin(ServerWorld world, NonGatherSystem.Session session, MobEntity joiningMob) { + ServerPlayerEntity requester = world.getServer().getPlayerManager().getPlayer(session.targetUuid()); + if (requester == null + || requester.getEntityWorld() != world + || !requester.isAlive() + || requester.isRemoved() + || joiningMob.isRemoved() + || session.activeInstanceUuid() == null) { + remove(session, world, false, null, "invalid_join_actor"); + } else if (!NonMobGatherEligibility.canInitiateAutoGather(joiningMob)) { + remove(session, world, false, null, "mob_not_auto_gatherable"); + } else if (AnimationService.isActorPendingOrActive(world, joiningMob.getUuid())) { + remove(session, world, false, null, "afw_actor_pending_or_active"); + } else { + AnimationStageInfo stage = AnimationService.getCurrentStage(world, session.activeInstanceUuid()); + if (stage == null) { + remove(session, world, false, null, "join_stage_unavailable"); + } else if (timedOut(world, session)) { + remove(session, world, true, NonGatherCancelReason.TIMEOUT, "timeout"); + } else { + int noLosTicks = joiningMob.canSee(requester) ? 0 : session.noLosTicks() + 1; + int tooFarTicks = joiningMob.squaredDistanceTo(requester) > 100.0 ? session.tooFarTicks() + 1 : 0; + if (!cancelledForDistance(session, world, noLosTicks, tooFarTicks)) { + joiningMob.getLookControl().lookAt(requester, 30.0F, 30.0F); + if (closeEnoughToJoin(joiningMob, requester)) { + Identifier joinAnimationId = resolveProtectedJoinAnimationId(world, session, joiningMob, requester); + if (joinAnimationId == null) { + NeedsOfNature.clearNoNGatherPair(world, List.of(joiningMob.getUuid(), requester.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + } else { + boolean voluntaryManualPeakJoin = NeedsOfNature.isVoluntaryManualPeakInstance(world, session.activeInstanceUuid()); + boolean accepted = NeedsOfNature.joinAnimationNow(world, session.activeInstanceUuid(), List.of(joiningMob), requester, joinAnimationId); + NeedsOfNature.clearNoNGatherPair(world, List.of(joiningMob.getUuid(), requester.getUuid())); + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + if (!accepted) { + NeedsOfNature.logRuntime( + "NoN join gather failed at close range: mobType={} player={} instance={} anim={}", + Registries.ENTITY_TYPE.getId(joiningMob.getType()), + requester.getName().getString(), + session.activeInstanceUuid(), + session.animationId() + ); + } else if (voluntaryManualPeakJoin) { + NonDestroyedSkinSystem.applyDestroyedSkinVoluntaryJoinInitialDamage(world, requester); + } + } + } else { + int repathCooldown = Math.max(0, session.repathCooldown() - 1); + if (repathCooldown <= 0) { + startMovingIntoTarget(joiningMob, requester); + repathCooldown = 5; + } + + update(session, repathCooldown, noLosTicks, tooFarTicks); + } + } + } + } + } + + private static void startMovingIntoTarget(MobEntity mob, Entity target) { + if (mob != null && target != null) { + double dx = target.getX() - mob.getX(); + double dz = target.getZ() - mob.getZ(); + double length = Math.sqrt(dx * dx + dz * dz); + double targetX = target.getX(); + double targetZ = target.getZ(); + if (length > 1.0E-4) { + targetX += dx / length * 1.0; + targetZ += dz / length * 1.0; + } + + mob.getNavigation().startMovingTo(targetX, target.getY(), targetZ, 1.15); + } + } + + @Nullable + private static Identifier resolveProtectedJoinAnimationId( + ServerWorld world, NonGatherSystem.Session session, MobEntity joiningMob, ServerPlayerEntity requester + ) { + List combinedActors = resolveJoinActors(world, session.activeInstanceUuid(), joiningMob); + if (combinedActors.isEmpty()) { + return session.animationId(); + } + + Identifier predecessorAnimationId = AnimationService.getActiveAnimationId(world, session.activeInstanceUuid()); + if (predecessorAnimationId == null) { + return null; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(session.animationId()); + if (definition == null) { + return session.animationId(); + } + + if (!NonInjectorMatchPolicy.allowsAutomaticNonMatch(world, definition, combinedActors)) { + boolean isAttack = NeedsOfNature.isInstanceAttack(world, session.activeInstanceUuid()); + NonGatherSystem.GatherCandidate fallback = findUnprotectedFallbackCandidate( + world, combinedActors, isAttack, joiningMob, requester, true, session.animationId(), predecessorAnimationId + ); + return fallback == null ? null : fallback.animationId(); + } + + if (!NeedsOfNature.isProtectedByAccessory(world, definition, combinedActors)) { + return session.animationId(); + } + + if (!NeedsOfNature.consumeProtectorForAnimation(world, definition, combinedActors)) { + return session.animationId(); + } + + boolean isAttack = NeedsOfNature.isInstanceAttack(world, session.activeInstanceUuid()); + NonGatherSystem.GatherCandidate fallback = findUnprotectedFallbackCandidate( + world, combinedActors, isAttack, joiningMob, requester, true, session.animationId(), predecessorAnimationId + ); + return fallback == null ? null : fallback.animationId(); + } + + private static List resolveJoinActors(ServerWorld world, @Nullable UUID activeInstanceUuid, MobEntity joiningMob) { + if (world != null && activeInstanceUuid != null && joiningMob != null) { + List actors = new ArrayList<>(); + + for (UUID actorUuid : NeedsOfNature.getInstanceActors(world, activeInstanceUuid)) { + Entity actor = world.getEntity(actorUuid); + if (actor == null) { + actor = world.getServer().getPlayerManager().getPlayer(actorUuid); + } + + if (actor != null && actor.getEntityWorld() == world && !actor.isRemoved()) { + actors.add(actor); + } + } + + if (joiningMob.getEntityWorld() == world && !joiningMob.isRemoved()) { + actors.add(joiningMob); + } + + actors.sort(Comparator.comparingInt(Entity::getId)); + return actors; + } else { + return List.of(); + } + } + + private static boolean timedOut(ServerWorld world, NonGatherSystem.Session session) { + return world.getTime() - session.startTick() > 3600L; + } + + @Nullable + private static NonGatherSystem.StartCandidate resolveProtectedStartCandidate( + ServerWorld world, NonGatherSystem.Session session, List actors, boolean isAttack, Entity attacker, @Nullable Entity gatherAnchor + ) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(session.animationId()); + if (definition == null) { + return new NonGatherSystem.StartCandidate(session.animationId(), session.actorKeys(), session.stages()); + } + + if (!NonInjectorMatchPolicy.allowsAutomaticNonMatch(world, definition, actors)) { + NonGatherSystem.GatherCandidate fallback = findUnprotectedFallbackCandidate( + world, actors, isAttack, attacker, gatherAnchor, true, session.animationId(), null + ); + return fallback == null ? null : new NonGatherSystem.StartCandidate(fallback.animationId(), fallback.actorKeys(), fallback.stages()); + } + + if (!NeedsOfNature.isProtectedByAccessory(world, definition, actors)) { + return new NonGatherSystem.StartCandidate(session.animationId(), session.actorKeys(), session.stages()); + } + + if (!NeedsOfNature.consumeProtectorForAnimation(world, definition, actors)) { + return new NonGatherSystem.StartCandidate(session.animationId(), session.actorKeys(), session.stages()); + } + + NonGatherSystem.GatherCandidate fallback = findUnprotectedFallbackCandidate( + world, actors, isAttack, attacker, gatherAnchor, true, session.animationId(), null + ); + return fallback == null ? null : new NonGatherSystem.StartCandidate(fallback.animationId(), fallback.actorKeys(), fallback.stages()); + } + + private static boolean cancelledForDistance(NonGatherSystem.Session session, ServerWorld world, int noLosTicks, int tooFarTicks) { + if (noLosTicks > 100) { + remove(session, world, true, NonGatherCancelReason.NO_LOS, "no_los"); + return true; + } else if (tooFarTicks > 200) { + remove(session, world, true, NonGatherCancelReason.TOO_FAR, "too_far"); + return true; + } else { + return false; + } + } + + private static void update(NonGatherSystem.Session session, int repathCooldown, int noLosTicks, int tooFarTicks) { + SESSIONS_BY_INITIATOR.put( + session.initiatorUuid(), + new NonGatherSystem.Session( + session.type(), + session.initiatorUuid(), + session.targetUuid(), + session.animationId(), + session.actorKeys(), + session.stages(), + session.attackMode(), + session.activeInstanceUuid(), + session.worldKey(), + session.startTick(), + repathCooldown, + noLosTicks, + tooFarTicks + ) + ); + } + + private static void remove(NonGatherSystem.Session session, ServerWorld world, boolean applyCooldown, @Nullable NonGatherCancelReason reason) { + remove(session, world, applyCooldown, reason, "unspecified"); + } + + private static void remove(NonGatherSystem.Session session, ServerWorld world, boolean applyCooldown, @Nullable NonGatherCancelReason reason, String detail) { + if (session != null && world != null) { + Entity initiator = world.getEntity(session.initiatorUuid()); + Entity target = world.getEntity(session.targetUuid()); + if (initiator instanceof MobEntity mob) { + mob.getNavigation().stop(); + } + + if (target instanceof MobEntity mob) { + mob.getNavigation().stop(); + } + + List actorUuids = List.of(session.initiatorUuid(), session.targetUuid()); + if (applyCooldown && reason != null) { + NeedsOfNature.applyNoNGatherCancellation(world, session.animationId(), actorUuids, reason); + } else { + NeedsOfNature.clearNoNGatherPair(world, actorUuids); + } + + SESSIONS_BY_INITIATOR.remove(session.initiatorUuid(), session); + } + } + + private static boolean closeEnoughToStart(Entity a, Entity b) { + if (a != null && b != null) { + return Math.abs(a.getY() - b.getY()) > 2.5 ? false : horizontalBoxDistanceSq(a.getBoundingBox(), b.getBoundingBox()) <= 0.0625; + } else { + return false; + } + } + + private static boolean closeEnoughToJoin(Entity a, Entity b) { + return closeEnoughToStart(a, b); + } + + private static double horizontalBoxDistanceSq(Box a, Box b) { + double dx = axisGap(a.minX, a.maxX, b.minX, b.maxX); + double dz = axisGap(a.minZ, a.maxZ, b.minZ, b.maxZ); + return dx * dx + dz * dz; + } + + private static double axisGap(double aMin, double aMax, double bMin, double bMax) { + if (aMax < bMin) { + return bMin - aMax; + } else { + return bMax < aMin ? aMin - bMax : 0.0; + } + } + + private static List sortedActors(Entity first, Entity second) { + List actors = new ArrayList<>(2); + actors.add(first); + actors.add(second); + actors.sort(Comparator.comparingInt(Entity::getId)); + return actors; + } + + public record GatherCandidate(Identifier animationId, List actorKeys, List stages) { + } + + private record Session( + NonGatherSystem.SessionType type, + UUID initiatorUuid, + UUID targetUuid, + Identifier animationId, + List actorKeys, + List stages, + boolean attackMode, + @Nullable UUID activeInstanceUuid, + RegistryKey worldKey, + long startTick, + int repathCooldown, + int noLosTicks, + int tooFarTicks + ) { + } + + private enum SessionType { + MOB_PLAYER, + MOB_MOB, + MOB_JOIN_ACTIVE_ANIMATION; + } + + private record StartCandidate(Identifier animationId, List actorKeys, List stages) { + } +} diff --git a/src/main/java/com/nonid/NonGenderSystem.java b/src/main/java/com/nonid/NonGenderSystem.java new file mode 100644 index 0000000..f56171f --- /dev/null +++ b/src/main/java/com/nonid/NonGenderSystem.java @@ -0,0 +1,408 @@ +package com.nonid; + +import com.nonid.api.NonChangeSource; +import com.nonid.data.NonEntityProfiles; +import com.nonid.network.GenderSelectionPromptS2CPayload; +import com.nonid.network.SetPlayerGenderC2SPayload; +import java.util.Map; +import java.util.Set; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents.Load; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.passive.CowEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; + +final class NonGenderSystem { + private static final String GENDER_TAG_MALE = "gender.male"; + private static final String GENDER_TAG_FEMALE = "gender.female"; + private static final String GENDER_TAG_SELECTED = "gender.selected"; + private static final int DEFAULT_PLAYER_GENDER_MASK = 2; + + private NonGenderSystem() { + } + + static void registerMobGenderTags() { + ServerEntityEvents.ENTITY_LOAD.register((Load)(entity, world) -> { + if (entity instanceof MobEntity) { + if (entity.getType() != NeedsOfNature.HORSE_LIQUID_COLLECTOR_ENTITY_TYPE) { + if (entity.getType() != NeedsOfNature.PREGNANCY_EGG_ENTITY_TYPE) { + Set tags = entity.getCommandTags(); + boolean hasMale = tags.contains("gender.male"); + boolean hasFemale = tags.contains("gender.female"); + if (!hasMale && !hasFemale) { + NonConfig.GenderSpawnChances chances = resolveGenderSpawnChances(entity); + int roll = world.getRandom().nextInt(100); + if (roll < chances.male()) { + entity.addCommandTag("gender.male"); + hasMale = true; + } else if (roll < chances.male() + chances.female()) { + entity.addCommandTag("gender.female"); + hasFemale = true; + } else { + entity.addCommandTag("gender.male"); + entity.addCommandTag("gender.female"); + hasMale = true; + hasFemale = true; + } + } + + if (entity instanceof GenderHolder holder) { + int mask = 0; + if (hasMale) { + mask |= 1; + } + + if (hasFemale) { + mask |= 2; + } + + holder.setGenderMask(mask); + } + + if (entity instanceof EnergyHolder holder && world instanceof ServerWorld serverWorld) { + holder.ensureEnergyInitialized(serverWorld); + } + } + } + } + }); + } + + static void registerCowMilkingGenderGate() { + UseEntityCallback.EVENT.register((UseEntityCallback)(player, world, hand, entity, hitResult) -> { + NonConfig config = NeedsOfNature.getConfig(); + if (config == null || !config.femaleCowOnlyMilking()) { + return ActionResult.PASS; + } + + if (entity instanceof CowEntity cow) { + if (cow.getType() != EntityType.COW) { + return ActionResult.PASS; + } else { + ItemStack stack = player.getStackInHand(hand); + if (!stack.isEmpty() && stack.isOf(Items.BUCKET)) { + return (ActionResult)(canCowBeMilked(cow) ? ActionResult.PASS : ActionResult.FAIL); + } else { + return ActionResult.PASS; + } + } + } else { + return ActionResult.PASS; + } + }); + } + + static void registerPlayerGenderRequests() { + ServerPlayNetworking.registerGlobalReceiver(SetPlayerGenderC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (player != null) { + handleClientGenderRequest(player, payload.mask(), payload.initialSelection()); + } + })); + } + + static void ensurePlayerGenderInitialized(ServerPlayerEntity player) { + if (player != null) { + int mask = 0; + if (player instanceof GenderHolder holder) { + mask = holder.getGenderMask() & 3; + } + + if (mask == 0) { + mask = resolveGenderMaskFromTags(player); + } + + if (mask != 0) { + markPlayerGenderSelected(player); + } + + if (mask == 0) { + NonConfig config = NeedsOfNature.getConfig(); + if (config.requirePlayerGenderSelectionOnJoin()) { + sendGenderSelectionPrompt(player); + } else { + int var3 = 2; + applyPlayerGenderMask(player, var3, true, NonChangeSource.SYSTEM); + } + } else { + applyGenderMask(player, mask); + if (NeedsOfNature.getConfig().requirePlayerGenderSelectionOnJoin() && !hasPlayerGenderSelected(player)) { + sendGenderSelectionPrompt(player); + } + } + } + } + + static int setPlayerGenderFromCommand(ServerPlayerEntity player, int mask) { + return setPlayerGenderFromCommand(player, mask, NonChangeSource.COMMAND); + } + + static int setPlayerGenderFromCommand(ServerPlayerEntity player, int mask, NonChangeSource source) { + int normalized = normalizeGenderMask(mask); + applyPlayerGenderMask(player, normalized, true, source == null ? NonChangeSource.COMMAND : source); + return normalized; + } + + static void resetPlayerGenderSelection(ServerPlayerEntity player) { + if (player != null) { + clearPlayerGenderSelectionState(player); + sendGenderSelectionPrompt(player); + } + } + + static boolean canClientChangeGenderFreely() { + return NeedsOfNature.getConfig().allowPlayerGenderChangeAnytime(); + } + + static boolean isStartingGenderAllowed(int mask) { + int normalized = normalizeGenderMask(mask); + int allowed = NeedsOfNature.getConfig().getAllowedStartingGenderMask(); + return (allowed & startingGenderChoiceBit(normalized)) != 0; + } + + private static void handleClientGenderRequest(ServerPlayerEntity player, int requestedMask, boolean initialSelection) { + NonConfig config = NeedsOfNature.getConfig(); + int normalized = normalizeGenderMask(requestedMask); + boolean selected = hasPlayerGenderSelected(player); + if (initialSelection) { + if (selected && !config.allowPlayerGenderChangeAnytime()) { + sendCurrentGenderFeedback(player, "Gender has already been selected on this server."); + } else if (!isStartingGenderAllowed(normalized)) { + sendGenderSelectionPrompt(player); + } else { + applyPlayerGenderMask(player, normalized, true, NonChangeSource.JOIN_SELECTION); + } + } else { + if (!config.allowPlayerGenderChangeAnytime()) { + if (!selected && config.requirePlayerGenderSelectionOnJoin()) { + sendGenderSelectionPrompt(player); + return; + } + + if (selected) { + sendCurrentGenderFeedback(player, "Gender changes are disabled by this server."); + return; + } + + if (!isStartingGenderAllowed(normalized)) { + sendGenderSelectionPrompt(player); + return; + } + } + + applyPlayerGenderMask(player, normalized, true, NonChangeSource.CLIENT_PREFERENCE); + } + } + + private static void applyPlayerGenderMask(ServerPlayerEntity player, int mask, boolean markSelected) { + applyPlayerGenderMask(player, mask, markSelected, NonChangeSource.SYSTEM); + } + + private static void applyPlayerGenderMask(ServerPlayerEntity player, int mask, boolean markSelected, NonChangeSource source) { + applyGenderMask(player, mask, source); + if (markSelected) { + markPlayerGenderSelected(player); + } + } + + private static void markPlayerGenderSelected(ServerPlayerEntity player) { + if (player != null) { + player.addCommandTag("gender.selected"); + } + } + + private static void clearPlayerGenderSelectionState(ServerPlayerEntity player) { + if (player != null) { + player.removeCommandTag("gender.selected"); + player.removeCommandTag("gender.male"); + player.removeCommandTag("gender.female"); + if (player instanceof GenderHolder holder) { + holder.setGenderMask(0); + } + } + } + + private static boolean hasPlayerGenderSelected(ServerPlayerEntity player) { + return player != null && player.getCommandTags().contains("gender.selected"); + } + + private static void sendGenderSelectionPrompt(ServerPlayerEntity player) { + if (player != null) { + NonConfig config = NeedsOfNature.getConfig(); + ServerPlayNetworking.send( + player, + new GenderSelectionPromptS2CPayload( + config.getAllowedStartingGenderMask(), currentOrDefaultPlayerMask(player), !config.allowPlayerGenderChangeAnytime() + ) + ); + } + } + + private static void sendCurrentGenderFeedback(ServerPlayerEntity player, String message) { + if (player != null) { + player.sendMessage(Text.literal("[NoN] " + message), false); + } + } + + private static int currentOrDefaultPlayerMask(ServerPlayerEntity player) { + if (player instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask != 0) { + return mask; + } + } + + return firstAllowedStartingMask(NeedsOfNature.getConfig().getAllowedStartingGenderMask()); + } + + private static int firstAllowedStartingMask(int allowedMask) { + int allowed = allowedMask & 3; + if ((allowedMask & 2) != 0) { + return 2; + } else if ((allowedMask & 1) != 0) { + return 1; + } else { + return (allowedMask & 4) != 0 ? 3 : 3; + } + } + + private static int startingGenderChoiceBit(int genderMask) { + return switch (genderMask & 3) { + case 1 -> 1; + case 2 -> 2; + case 3 -> 4; + default -> 2; + }; + } + + private static int normalizeGenderMask(int mask) { + int normalized = mask & 3; + return normalized == 0 ? 2 : normalized; + } + + static NonConfig.GenderSpawnChances resolveGenderSpawnChances(Entity entity) { + return NonEntityProfiles.resolve(entity).genderSpawn(); + } + + static Map resolveEffectiveGenderSpawnMap() { + return NonEntityProfiles.resolveEffectiveGenderSpawnMap(); + } + + static boolean hasMaleTag(Entity entity) { + return entity != null && entity.getCommandTags().contains("gender.male"); + } + + static boolean hasFemaleTag(Entity entity) { + return entity != null && entity.getCommandTags().contains("gender.female"); + } + + static boolean isOnlyMale(Entity entity) { + return entity instanceof GenderHolder holder ? (holder.getGenderMask() & 3) == 1 : false; + } + + static boolean hasMaleGender(Entity entity) { + if (entity instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask != 0) { + return (mask & 1) != 0; + } + } + + return hasMaleTag(entity); + } + + static boolean hasFemaleGender(Entity entity) { + if (entity instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask != 0) { + return (mask & 2) != 0; + } + } + + return hasFemaleTag(entity); + } + + static NonLiquidTankType getLiquidTankType(Entity entity) { + return isOnlyMale(entity) ? NonLiquidTankType.A : NonLiquidTankType.V; + } + + static void applyGenderMask(Entity entity, int mask) { + applyGenderMask(entity, mask, NonChangeSource.SYSTEM); + } + + static boolean isOnlyFemale(Entity entity) { + return entity == null ? false : hasFemaleGender(entity) && !hasMaleGender(entity); + } + + static void applyGenderMask(Entity entity, int mask, NonChangeSource source) { + if (entity != null) { + int beforeMask = entity instanceof GenderHolder existingHolder ? existingHolder.getGenderMask() & 3 : 0; + int normalizedMask = mask & 3; + if (normalizedMask == 0) { + normalizedMask = 2; + } + + entity.removeCommandTag("gender.male"); + entity.removeCommandTag("gender.female"); + if ((normalizedMask & 1) != 0) { + entity.addCommandTag("gender.male"); + } + + if ((normalizedMask & 2) != 0) { + entity.addCommandTag("gender.female"); + } + + if (entity instanceof GenderHolder holder) { + holder.setGenderMask(normalizedMask); + } + + if (normalizedMask == 1 && entity instanceof ServerPlayerEntity player) { + if (NonMessSystem.normalizeMaleOnlyMess(player, false)) { + NonMessSystem.broadcastMessState(player); + } + + NeedsOfNature.clearPregnancyState(player, true); + } + + if (entity instanceof ServerPlayerEntity player && (beforeMask & 3) != (normalizedMask & 3)) { + NonApiInternals.fireGenderChanged(player, normalizedMask, source == null ? NonChangeSource.SYSTEM : source); + NonPregnancyConceptionSystem.syncState(player); + } + } + } + + private static boolean canCowBeMilked(CowEntity cow) { + if (cow == null) { + return true; + } else { + return cow instanceof GenderHolder holder ? holder.hasGender(2) : cow.getCommandTags().contains("gender.female"); + } + } + + private static int resolveGenderMaskFromTags(Entity entity) { + if (entity == null) { + return 0; + } + + Set tags = entity.getCommandTags(); + int mask = 0; + if (tags.contains("gender.male")) { + mask |= 1; + } + + if (tags.contains("gender.female")) { + mask |= 2; + } + + return mask; + } +} diff --git a/src/main/java/com/nonid/NonInjectorMatchPolicy.java b/src/main/java/com/nonid/NonInjectorMatchPolicy.java new file mode 100644 index 0000000..bb73f6f --- /dev/null +++ b/src/main/java/com/nonid/NonInjectorMatchPolicy.java @@ -0,0 +1,47 @@ +package com.nonid; + +import com.nonid.data.NonLiquidRoles; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import java.util.List; +import net.minecraft.entity.Entity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; + +public final class NonInjectorMatchPolicy { + private NonInjectorMatchPolicy() { + } + + public static boolean allowsAutomaticNonMatch(ServerWorld world, AnimationDefinitions.Definition definition, List actors) { + if (world == null || definition == null || actors == null || actors.isEmpty()) { + return false; + } + + if (!hasMaleOnlyPlayer(actors)) { + return true; + } + + NonLiquidRoles.LiquidRoles roles = AnimationGameplayMetadata.liquidRoles(definition.id()); + if (roles != null && !roles.injectorRoles().isEmpty()) { + boolean hasV = roles.injectorRoles().containsValue(NonLiquidRoles.InjectorRole.V); + boolean hasA = roles.injectorRoles().containsValue(NonLiquidRoles.InjectorRole.A); + if (!hasV) { + return true; + } else { + return hasA ? false : NeedsOfNature.getConfig().convertMaleOnlyVInjectionsToA(); + } + } else { + return true; + } + } + + private static boolean hasMaleOnlyPlayer(List actors) { + for (Entity actor : actors) { + if (actor instanceof ServerPlayerEntity && NonGenderSystem.isOnlyMale(actor)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/nonid/NonItemSystem.java b/src/main/java/com/nonid/NonItemSystem.java new file mode 100644 index 0000000..7812669 --- /dev/null +++ b/src/main/java/com/nonid/NonItemSystem.java @@ -0,0 +1,208 @@ +package com.nonid; + +import com.nonid.item.EnergyStabilizerItem; +import com.nonid.potion.NonPotions; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.FoodComponent; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.component.type.TooltipDisplayComponent; +import net.minecraft.component.type.FoodComponent.Builder; +import net.minecraft.entity.EntityType; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.item.ItemGroup.Entries; +import net.minecraft.potion.Potion; +import net.minecraft.registry.Registries; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonItemSystem { + private static final FoodComponent LIQUID_BOTTLE_FOOD = new Builder().nutrition(1).saturationModifier(0.0F).alwaysEdible().build(); + private static final FoodComponent FERTILE_NECTAR_FOOD = new Builder().nutrition(7).saturationModifier(0.0F).alwaysEdible().build(); + private static final TooltipDisplayComponent LIQUID_BOTTLE_TOOLTIP = TooltipDisplayComponent.DEFAULT.with(DataComponentTypes.POTION_CONTENTS, true); + + private NonItemSystem() { + } + + static void giveItemOrDrop(ServerPlayerEntity player, ItemStack stack) { + if (player != null && stack != null && !stack.isEmpty()) { + if (!player.getInventory().insertStack(stack)) { + player.dropItem(stack, false); + } + } + } + + static boolean consumeOneGlassBottle(ServerPlayerEntity player, Hand hand) { + if (player != null && hand != null) { + ItemStack stack = player.getStackInHand(hand); + if (!stack.isEmpty() && stack.isOf(Items.GLASS_BOTTLE)) { + stack.decrement(1); + return true; + } else { + return false; + } + } else { + return false; + } + } + + static void refundGlassBottle(ServerPlayerEntity player) { + giveItemOrDrop(player, new ItemStack(Items.GLASS_BOTTLE)); + } + + static ItemStack createBottleStackForTank(LiquidHolder holder) { + Identifier entityTypeId = null; + if (holder != null && holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY) { + entityTypeId = holder.getLiquidEntityTypeId(); + } + + return createLiquidBottleStack(entityTypeId); + } + + static ItemStack createLiquidBottleStack(@Nullable Identifier entityTypeId) { + int tint = NonLiquidSystem.resolveBottleTintRgb(entityTypeId); + PotionContentsComponent contents = new PotionContentsComponent( + Optional.of(Registries.POTION.getEntry(NonPotions.LIQUID)), Optional.of(tint), List.of(), Optional.empty() + ); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, contents); + stack.set(DataComponentTypes.MAX_STACK_SIZE, 16); + stack.set(DataComponentTypes.FOOD, LIQUID_BOTTLE_FOOD); + NonPotions.applyHoneyBottleDrinkSound(stack); + stack.set(DataComponentTypes.TOOLTIP_DISPLAY, LIQUID_BOTTLE_TOOLTIP); + NonPotions.setLiquidBottleEntityTypeId(stack, entityTypeId); + if (entityTypeId == null) { + stack.set(DataComponentTypes.CUSTOM_NAME, Text.translatable("item.needsofnature.mixed_liquid_bottle").copy().styled(style -> style.withItalic(false))); + } else { + stack.set( + DataComponentTypes.CUSTOM_NAME, + Text.translatable("item.needsofnature.entity_liquid_bottle", new Object[]{resolveEntityNameText(entityTypeId)}) + .copy() + .styled(style -> style.withItalic(false)) + ); + } + + return stack; + } + + static void addEntityLiquidAndStabilizersToItemGroup(Entries entries, @Nullable NonConfig config) { + for (String rawEntityId : NonLiquidSystem.resolveEffectiveLiquidGainMap().keySet()) { + Identifier entityId = Identifier.tryParse(rawEntityId); + if (entityId != null) { + entries.add(createLiquidBottleStack(entityId)); + entries.add(createEnergyStabilizerStack(entityId)); + } + } + } + + static void addNonPotionVariantsToItemGroup(Entries entries) { + List> nonPotions = new ArrayList<>(); + + for (Potion potion : Registries.POTION) { + Identifier potionId = Registries.POTION.getId(potion); + if (potionId != null && "needsofnature".equals(potionId.getNamespace())) { + nonPotions.add(Registries.POTION.getEntry(potion)); + } + } + + entries.add(createLiquidBottleStack(null)); + + for (RegistryEntry potionEntry : nonPotions) { + if (potionEntry.value() != NonPotions.LIQUID) { + entries.add(createPotionVariantStack(Items.POTION, potionEntry)); + } + } + + for (RegistryEntry potionEntry : nonPotions) { + if (potionEntry.value() != NonPotions.LIQUID) { + entries.add(createPotionVariantStack(Items.SPLASH_POTION, potionEntry)); + } + } + + for (RegistryEntry potionEntry : nonPotions) { + if (potionEntry.value() != NonPotions.LIQUID) { + entries.add(createPotionVariantStack(Items.LINGERING_POTION, potionEntry)); + } + } + + for (RegistryEntry potionEntry : nonPotions) { + if (potionEntry.value() != NonPotions.LIQUID) { + entries.add(createPotionVariantStack(Items.TIPPED_ARROW, potionEntry)); + } + } + } + + static ItemStack createPotionVariantStack(Item item, RegistryEntry potionEntry) { + ItemStack stack = new ItemStack(item); + stack.set(DataComponentTypes.POTION_CONTENTS, new PotionContentsComponent(potionEntry)); + if (item == Items.POTION && potionEntry.value() == NonPotions.FERTILE_NECTAR) { + stack.set(DataComponentTypes.FOOD, FERTILE_NECTAR_FOOD); + stack.set(DataComponentTypes.MAX_STACK_SIZE, 16); + NonPotions.applyHoneyBottleDrinkSound(stack); + } + + return stack; + } + + static boolean isNonPotionVariantStack(ItemStack stack) { + if (stack != null && !stack.isEmpty()) { + Item item = stack.getItem(); + boolean potionLike = item == Items.POTION || item == Items.SPLASH_POTION || item == Items.LINGERING_POTION || item == Items.TIPPED_ARROW; + if (!potionLike) { + return false; + } + + PotionContentsComponent contents = (PotionContentsComponent)stack.getOrDefault(DataComponentTypes.POTION_CONTENTS, PotionContentsComponent.DEFAULT); + Optional> potionOpt = contents.potion(); + if (potionOpt.isEmpty()) { + return false; + } + + Identifier potionId = Registries.POTION.getId((Potion)potionOpt.get().value()); + return potionId != null && "needsofnature".equals(potionId.getNamespace()); + } else { + return false; + } + } + + static ItemStack createEnergyStabilizerStack(@Nullable Identifier entityTypeId) { + return EnergyStabilizerItem.createStackForEntity(entityTypeId); + } + + private static Text resolveEntityNameText(Identifier entityTypeId) { + return (Text)(entityTypeId == null + ? Text.literal("未知") + : Registries.ENTITY_TYPE + .getOptionalValue(entityTypeId) + .map(EntityType::getName) + .orElseGet(() -> Text.literal(formatFallbackEntityName(entityTypeId)))); + } + + private static String formatFallbackEntityName(Identifier entityTypeId) { + String[] parts = entityTypeId.getPath().replace('/', '_').split("_"); + StringBuilder out = new StringBuilder(); + + for (String part : parts) { + if (part != null && !part.isEmpty()) { + if (!out.isEmpty()) { + out.append(' '); + } + + out.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) { + out.append(part.substring(1)); + } + } + } + + return out.isEmpty() ? entityTypeId.toString() : out.toString(); + } +} diff --git a/src/main/java/com/nonid/NonLiquidSystem.java b/src/main/java/com/nonid/NonLiquidSystem.java new file mode 100644 index 0000000..cfaf472 --- /dev/null +++ b/src/main/java/com/nonid/NonLiquidSystem.java @@ -0,0 +1,301 @@ +package com.nonid; + +import com.nonid.client.NonLiquidColors; +import com.nonid.data.NonLiquidGainOverrides; +import com.nonid.effect.NonStatusEffects; +import com.nonid.network.LiquidStateS2CPayload; +import com.nonid.particle.NonParticles; +import com.nonid.potion.NonPotions; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; + +final class NonLiquidSystem { + private static final double LIQUID_SNEAK_DECAY_MULTIPLIER = 16.0; + private static final double LIQUID_WATER_DECAY_MULTIPLIER = 2.0; + private static final double LIQUID_DECAY_MIN_MULT = 0.1; + private static final double LIQUID_DECAY_MAX_MULT = 1.5; + private static final double LIQUID_SNEAK_PARTICLE_BACK_OFFSET = 0.28; + private static final Map LIQUID_DECAY_REMAINDER = new ConcurrentHashMap<>(); + + private NonLiquidSystem() { + } + + static void clearRuntimeState() { + LIQUID_DECAY_REMAINDER.clear(); + } + + static void syncLiquidState(ServerPlayerEntity player) { + if (player != null) { + if (player instanceof LiquidHolder holder) { + enforceLiquidCapacity(holder); + int stored = holder.getLiquidStored(); + int capacity = holder.getLiquidCapacity(); + updateFilledEffect(player, holder); + int tint = resolveLiquidTintRgb(holder); + ServerPlayNetworking.send(player, new LiquidStateS2CPayload(stored, capacity, tint)); + NonApiInternals.fireLiquidChanged(player); + NonMessSystem.broadcastMessState(player); + NonAdvancementHooks.checkLiquidAdvancements(player, holder); + } + } + } + + private static void enforceLiquidCapacity(LiquidHolder holder) { + if (holder != null) { + int capacity = holder.getLiquidCapacity(); + int stored = holder.getLiquidStored(); + if (capacity <= 0) { + if (stored > 0) { + holder.setLiquidStored(0); + } + } else { + if (stored > capacity) { + holder.setLiquidStored(capacity); + } + } + } + } + + static void syncLiquidStateToAll(@Nullable MinecraftServer server) { + if (server != null) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + syncLiquidState(player); + } + } + } + + static void refreshLiquidBottleTintsToAll(@Nullable MinecraftServer server) { + if (server != null) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + refreshLiquidBottleTints(player); + } + } + } + + private static void refreshLiquidBottleTints(ServerPlayerEntity player) { + if (player != null) { + PlayerInventory inventory = player.getInventory(); + + for (int i = 0; i < inventory.size(); i++) { + refreshLiquidBottleTint(inventory.getStack(i)); + } + + refreshLiquidBottleTint(player.currentScreenHandler.getCursorStack()); + } + } + + private static void refreshLiquidBottleTint(ItemStack stack) { + if (NonPotions.isLiquidBottle(stack)) { + Identifier entityTypeId = NonPotions.getLiquidBottleEntityTypeId(stack); + int tint = resolveBottleTintRgb(entityTypeId); + PotionContentsComponent current = (PotionContentsComponent)stack.getOrDefault(DataComponentTypes.POTION_CONTENTS, PotionContentsComponent.DEFAULT); + PotionContentsComponent updated = new PotionContentsComponent(current.potion(), Optional.of(tint), List.of(), Optional.empty()); + stack.set(DataComponentTypes.POTION_CONTENTS, updated); + } + } + + static int resolveLiquidTintRgb(LiquidHolder holder) { + if (holder != null && holder.getLiquidStored() > 0) { + return holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY + ? resolveEntityLiquidColorRgb(holder.getLiquidEntityTypeId()) + : resolveMixedLiquidColorRgb(); + } else { + return resolveMixedLiquidColorRgb(); + } + } + + static int resolveBottleTintRgb(@Nullable Identifier entityTypeId) { + return entityTypeId == null ? resolveMixedLiquidColorRgb() : resolveEntityLiquidColorRgb(entityTypeId); + } + + private static int resolveEntityLiquidColorRgb(@Nullable Identifier entityTypeId) { + if (entityTypeId == null) { + return 16776427; + } + + String key = entityTypeId.toString(); + String configuredHex = NeedsOfNature.getConfig().getLiquidColorByEntity().get(key); + Integer configuredRgb = NonLiquidColors.parseHexColor(configuredHex); + if (configuredRgb != null) { + return configuredRgb; + } + + Integer packOverride = NonLiquidGainOverrides.getColorRgb(entityTypeId); + return packOverride != null ? packOverride : 16776427; + } + + private static int resolveMixedLiquidColorRgb() { + String configuredHex = NeedsOfNature.getConfig().getLiquidColorByEntity().get("needsofnature:mixed"); + Integer configuredRgb = NonLiquidColors.parseHexColor(configuredHex); + if (configuredRgb != null) { + return configuredRgb; + } + + Integer packOverride = NonLiquidGainOverrides.getMixedColorRgb(); + return packOverride != null ? packOverride : 15920063; + } + + static Map resolveEffectiveLiquidGainMap() { + Map merged = new LinkedHashMap<>(NonLiquidGainOverrides.getGainByEntity()); + merged.putAll(NeedsOfNature.getConfig().getLiquidGainByEntity()); + return Map.copyOf(merged); + } + + static Map resolveEffectiveLiquidColorMap() { + Map merged = new LinkedHashMap<>(NonLiquidGainOverrides.getColorByEntity()); + String packMixedHex = NonLiquidGainOverrides.getMixedColorHex(); + if (packMixedHex != null) { + merged.put("needsofnature:mixed", packMixedHex); + } + + merged.putAll(NeedsOfNature.getConfig().getLiquidColorByEntity()); + return Map.copyOf(merged); + } + + static void tickLiquidDecay(ServerWorld world) { + if (world != null) { + if (!world.getPlayers().isEmpty()) { + NonConfig config = NeedsOfNature.getConfig(); + boolean liquidTankEnabled = config.isLiquidTankEnabled(); + double decayPerSecond = config.getLiquidDecayPerSecond(); + + for (ServerPlayerEntity player : world.getPlayers()) { + if (player instanceof LiquidHolder holder) { + if (!liquidTankEnabled) { + LIQUID_DECAY_REMAINDER.remove(player.getUuid()); + if (holder.getLiquidStored() > 0) { + holder.setLiquidStored(0); + syncLiquidState(player); + } + + updateFilledEffect(player, holder); + } else { + updateFilledEffect(player, holder); + if (!(decayPerSecond <= 0.0) && NeedsOfNature.getActivePlayerInstance(player) == null) { + int stored = holder.getLiquidStored(); + if (stored <= 0) { + LIQUID_DECAY_REMAINDER.remove(player.getUuid()); + updateFilledEffect(player, holder); + } else { + NonAccessoryEffects accessoryEffects = NonTrinketsIntegration.getActiveTankAccessoryEffects(player); + double decayPerTick = decayPerSecond / 20.0; + boolean equalizedDecay = accessoryEffects.equalizeLiquidDecayContext(); + if (!equalizedDecay && player.isSneaking()) { + decayPerTick *= 16.0; + } + + if (!equalizedDecay && (player.isTouchingWater() || player.isSubmergedInWater())) { + decayPerTick *= 2.0; + } + + int capacity = holder.getLiquidCapacity(); + double fillRatio = 0.0; + if (capacity > 0) { + fillRatio = Math.min(1.0, Math.max(0.0, (double)stored / capacity)); + } + + double curve = 1.0 - Math.log1p((1.0 - fillRatio) * 9.0) / Math.log1p(9.0); + double decayMultiplier = 0.1 + 1.4 * curve; + decayPerTick *= decayMultiplier; + decayPerTick *= accessoryEffects.liquidDecayMultiplier(); + double remainder = LIQUID_DECAY_REMAINDER.getOrDefault(player.getUuid(), 0.0); + double accum = remainder + decayPerTick; + int decay = (int)Math.floor(accum); + remainder = accum - decay; + LIQUID_DECAY_REMAINDER.put(player.getUuid(), remainder); + if (decay > 0) { + int next = Math.max(0, stored - decay); + if (next != stored) { + holder.setLiquidStored(next); + syncLiquidState(player); + } + + spawnLiquidDecayParticles(world, player); + } + } + } + } + } + } + } + } + } + + static void updateFilledEffect(ServerPlayerEntity player, LiquidHolder holder) { + if (player != null && holder != null) { + int amplifier = getAmplifier(holder); + RegistryEntry entry = Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FILLED); + StatusEffectInstance current = player.getStatusEffect(entry); + if (amplifier < 0) { + player.removeStatusEffect(entry); + } else { + if (current != null) { + if (current.getAmplifier() == amplifier) { + return; + } + + player.removeStatusEffect(entry); + } + + player.addStatusEffect(new StatusEffectInstance(entry, -1, amplifier, false, false, true)); + } + } + } + + private static int getAmplifier(LiquidHolder holder) { + int capacity = holder.getLiquidCapacity(); + int stored = holder.getLiquidStored(); + int amplifier = -1; + if (capacity > 0) { + float ratio = Math.max(0.0F, Math.min(1.0F, (float)stored / capacity)); + if (ratio >= 0.8F) { + amplifier = 2; + } else if (ratio >= 0.5F) { + amplifier = 1; + } else if (ratio >= 0.3F) { + amplifier = 0; + } + } + + return amplifier; + } + + private static void spawnLiquidDecayParticles(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + int tintRgb = player instanceof LiquidHolder holder ? resolveLiquidTintRgb(holder) : resolveBottleTintRgb(null); + Vec3d spawnPos = resolveLiquidDecayParticlePos(player); + world.spawnParticles(NonParticles.liquidFalling(tintRgb), spawnPos.x, spawnPos.y, spawnPos.z, 1, 0.0, 0.0, 0.0, 0.0); + } + } + + private static Vec3d resolveLiquidDecayParticlePos(ServerPlayerEntity player) { + double x = player.getX(); + double z = player.getZ(); + if (player.isSneaking()) { + double yawRad = Math.toRadians(player.bodyYaw); + x += Math.sin(yawRad) * 0.28; + z -= Math.cos(yawRad) * 0.28; + } + + return new Vec3d(x, player.getBodyY(0.4), z); + } +} diff --git a/src/main/java/com/nonid/NonLiquidTankType.java b/src/main/java/com/nonid/NonLiquidTankType.java new file mode 100644 index 0000000..7a102a2 --- /dev/null +++ b/src/main/java/com/nonid/NonLiquidTankType.java @@ -0,0 +1,22 @@ +package com.nonid; + +import com.nonid.data.NonLiquidRoles; + +public enum NonLiquidTankType { + V("v"), + A("a"); + + private final String slotName; + + NonLiquidTankType(String slotName) { + this.slotName = slotName; + } + + public String trinketsSlotId() { + return "legs/" + this.slotName; + } + + public boolean accepts(NonLiquidRoles.InjectorRole role) { + return this == V && role == NonLiquidRoles.InjectorRole.V || this == A && role == NonLiquidRoles.InjectorRole.A; + } +} diff --git a/src/main/java/com/nonid/NonMessSystem.java b/src/main/java/com/nonid/NonMessSystem.java new file mode 100644 index 0000000..9d97c33 --- /dev/null +++ b/src/main/java/com/nonid/NonMessSystem.java @@ -0,0 +1,264 @@ +package com.nonid; + +import com.nonid.data.NonLiquidRoles; +import com.nonid.network.MessStateS2CPayload; +import com.nonid.particle.NonParticles; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.entity.Entity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; + +final class NonMessSystem { + private static final Map MESS_CLEAN_NEXT_TICKS = new ConcurrentHashMap<>(); + + private NonMessSystem() { + } + + static void clearRuntimeState() { + MESS_CLEAN_NEXT_TICKS.clear(); + } + + static void incrementMessOnPeak( + ServerWorld world, List actorUuids, List actorKeys, Identifier animationId, Set suppliedInjectorRoles + ) { + if (NeedsOfNature.isMessSystemEnabled()) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + if (suppliedInjectorRoles != null && !suppliedInjectorRoles.isEmpty()) { + boolean hasV = suppliedInjectorRoles.contains(NonLiquidRoles.InjectorRole.V); + boolean hasA = suppliedInjectorRoles.contains(NonLiquidRoles.InjectorRole.A); + boolean hasM = suppliedInjectorRoles.contains(NonLiquidRoles.InjectorRole.M); + if (hasV || hasA || hasM) { + for (ServerPlayerEntity player : NeedsOfNature.collectPlayerReceiverActors(world, actorUuids, actorKeys, animationId)) { + if (player instanceof MessHolder holder) { + normalizeMaleOnlyMess(player, false); + int messGain = rollMessGain(world, player); + if (messGain > 0) { + if (hasV) { + if (NonGenderSystem.isOnlyMale(player)) { + if (NeedsOfNature.getConfig().convertMaleOnlyVInjectionsToA()) { + holder.setAMess(clampMess(holder.getAMess() + messGain)); + } + } else { + holder.setVMess(clampMess(holder.getVMess() + messGain)); + } + } + + if (hasA) { + holder.setAMess(clampMess(holder.getAMess() + messGain)); + } + + if (hasM) { + holder.setMMess(clampMess(holder.getMMess() + messGain)); + } + + broadcastMessState(player); + if (holder.getVMess() >= 10 && holder.getAMess() >= 10 && holder.getMMess() >= 10) { + NonAdvancementHooks.grantMessedUp(player); + } + } + } + } + } + } + } + } + } + + private static int rollMessGain(ServerWorld world, ServerPlayerEntity player) { + double multiplier = NonTrinketsIntegration.getActiveTankAccessoryEffects(player).messGainMultiplier(); + if (Double.isFinite(multiplier) && !(multiplier <= 0.0)) { + int whole = (int)Math.floor(multiplier); + double fractional = multiplier - whole; + if (fractional > 0.0 && world.getRandom().nextDouble() < fractional) { + whole++; + } + + return Math.max(0, whole); + } else { + return 0; + } + } + + static void clearMess(ServerPlayerEntity player) { + if (player != null && player instanceof MessHolder holder) { + holder.setVMess(0); + holder.setAMess(0); + holder.setMMess(0); + MESS_CLEAN_NEXT_TICKS.remove(player.getUuid()); + broadcastMessState(player); + } + } + + static void clearMessStoredState(ServerPlayerEntity player) { + if (player != null && player instanceof MessHolder holder) { + holder.setVMess(0); + holder.setAMess(0); + holder.setMMess(0); + MESS_CLEAN_NEXT_TICKS.remove(player.getUuid()); + } + } + + static int clampMess(int value) { + return Math.max(0, Math.min(10, value)); + } + + static void tickMessCleaning(ServerWorld world, long now) { + if (NeedsOfNature.isMessSystemEnabled()) { + if (world != null) { + for (ServerPlayerEntity player : world.getPlayers()) { + if (player instanceof MessHolder holder) { + normalizeMaleOnlyMess(player, false); + UUID playerUuid = player.getUuid(); + if (!hasAnyMess(holder)) { + MESS_CLEAN_NEXT_TICKS.remove(playerUuid); + } else { + boolean inWater = player.isTouchingWater() || player.isSubmergedInWater(); + boolean inRain = !inWater && isExposedToRain(world, player); + if (!inWater && !inRain) { + MESS_CLEAN_NEXT_TICKS.remove(playerUuid); + } else { + Long scheduledTick = MESS_CLEAN_NEXT_TICKS.get(playerUuid); + if (scheduledTick == null) { + MESS_CLEAN_NEXT_TICKS.put(playerUuid, now + 20L); + } else { + long nextTick = scheduledTick; + if (now >= nextTick) { + boolean changed = decrementMess(holder); + if (changed) { + broadcastMessState(player); + if (inWater) { + spawnMessCleaningWaterParticles(world, player); + } + } + + if (hasAnyMess(holder)) { + MESS_CLEAN_NEXT_TICKS.put(playerUuid, now + 20L); + } else { + MESS_CLEAN_NEXT_TICKS.remove(playerUuid); + } + } + } + } + } + } + } + } + } + } + + static boolean normalizeMaleOnlyMess(Entity entity, boolean convertVToA) { + if (NonGenderSystem.isOnlyMale(entity) && entity instanceof MessHolder holder) { + int v = clampMess(holder.getVMess()); + if (v <= 0) { + return false; + } + + holder.setVMess(0); + if (convertVToA) { + holder.setAMess(clampMess(holder.getAMess() + v)); + } + + return true; + } else { + return false; + } + } + + static void broadcastMessState(ServerPlayerEntity player) { + if (player != null) { + NonApiInternals.fireMessChanged(player); + MinecraftServer server = player.getEntityWorld().getServer(); + if (server != null) { + for (ServerPlayerEntity target : server.getPlayerManager().getPlayerList()) { + sendMessState(target, player); + } + } + } + } + + static void syncMessStates(ServerPlayerEntity target) { + if (target != null) { + MinecraftServer server = target.getEntityWorld().getServer(); + if (server != null) { + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + sendMessState(target, player); + } + } + } + } + + private static void sendMessState(ServerPlayerEntity target, ServerPlayerEntity player) { + if (target != null && player != null) { + if (ServerPlayNetworking.canSend(target, MessStateS2CPayload.ID)) { + int v = 0; + int a = 0; + int m = 0; + if (NeedsOfNature.isMessSystemEnabled() && player instanceof MessHolder holder) { + normalizeMaleOnlyMess(player, false); + v = clampMess(holder.getVMess()); + a = clampMess(holder.getAMess()); + m = clampMess(holder.getMMess()); + } + + int tint = 16777215; + int stored = 0; + int capacity = 0; + if (player instanceof LiquidHolder liquidHolder) { + tint = NonLiquidSystem.resolveLiquidTintRgb(liquidHolder); + stored = Math.max(0, liquidHolder.getLiquidStored()); + capacity = Math.max(0, liquidHolder.getLiquidCapacity()); + } + + ServerPlayNetworking.send(target, new MessStateS2CPayload(player.getUuid(), v, a, m, tint, stored, capacity)); + } + } + } + + private static boolean hasAnyMess(MessHolder holder) { + return holder != null && (holder.getVMess() > 0 || holder.getAMess() > 0 || holder.getMMess() > 0); + } + + private static boolean decrementMess(MessHolder holder) { + if (holder == null) { + return false; + } + + int beforeV = clampMess(holder.getVMess()); + int beforeA = clampMess(holder.getAMess()); + int beforeM = clampMess(holder.getMMess()); + if (beforeV <= 0 && beforeA <= 0 && beforeM <= 0) { + return false; + } + + holder.setVMess(Math.max(0, beforeV - 1)); + holder.setAMess(Math.max(0, beforeA - 1)); + holder.setMMess(Math.max(0, beforeM - 1)); + return true; + } + + private static boolean isExposedToRain(ServerWorld world, ServerPlayerEntity player) { + return world != null && player != null && world.isRaining() ? world.hasRain(player.getBlockPos()) : false; + } + + private static void spawnMessCleaningWaterParticles(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + int tintRgb = player instanceof LiquidHolder holder ? NonLiquidSystem.resolveLiquidTintRgb(holder) : NonLiquidSystem.resolveBottleTintRgb(null); + int count = 2 + world.getRandom().nextInt(3); + double y = player.getBodyY(0.45); + + for (int i = 0; i < count; i++) { + double ox = (world.getRandom().nextDouble() - 0.5) * player.getWidth(); + double oy = (world.getRandom().nextDouble() - 0.5) * 0.45; + double oz = (world.getRandom().nextDouble() - 0.5) * player.getWidth(); + world.spawnParticles(NonParticles.liquidWater(tintRgb), player.getX() + ox, y + oy, player.getZ() + oz, 1, 0.0, -0.01, 0.0, 0.0); + } + } + } +} diff --git a/src/main/java/com/nonid/NonMobEnergySystem.java b/src/main/java/com/nonid/NonMobEnergySystem.java new file mode 100644 index 0000000..b547d81 --- /dev/null +++ b/src/main/java/com/nonid/NonMobEnergySystem.java @@ -0,0 +1,227 @@ +package com.nonid; + +import com.nonid.data.NonEntityProfiles; +import com.nonid.internal.animation.data.AnimationDefinitions; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.passive.HorseEntity; +import net.minecraft.registry.Registries; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public final class NonMobEnergySystem { + public static final int DEFAULT_TICKS_PER_ENERGY = 120; + public static final int PARTNER_SCAN_INTERVAL_TICKS = 40; + public static final int POST_ATTEMPT_COOLDOWN_TICKS = 100; + public static final int ROLL_FAIL_COOLDOWN_TICKS = 1200; + public static final int ENERGY_JOIN_MIN = 70; + public static final int ENERGY_MOB_MATCH_MIN = 120; + public static final int ENERGY_ATTACK = 200; + public static final double PARTNER_SCAN_RADIUS = 20.0; + public static final double PLAYER_COOLDOWN_BYPASS_DIST_SQ = 64.0; + public static final double PLAYER_ANIMATION_LINGER_RADIUS = 3.0; + public static final double PLAYER_ANIMATION_LINGER_RADIUS_SQ = 9.0; + public static final double PLAYER_ANIMATION_LINGER_MIN_DISTANCE = 1.5; + public static final double PLAYER_ANIMATION_LINGER_MIN_DISTANCE_SQ = 2.25; + public static final double PLAYER_ANIMATION_LINGER_MOVE_SPEED = 1.1; + private static final int GENDER_MIXED = 0; + private static final int GENDER_MALE = 1; + private static final int GENDER_FEMALE = 2; + + private NonMobEnergySystem() { + } + + public static double passiveGainScale(MobEntity mob, float multiplier, float drift, boolean nearAnimation, double nearAnimationMultiplier) { + double rate = NeedsOfNature.getConfig().getEnergyGainRate(); + double profileMultiplier = NonEntityProfiles.resolveEnergyGainMultiplier(mob); + double scale = multiplier * drift * rate * profileMultiplier; + if (nearAnimation) { + scale *= Math.max(1.0, nearAnimationMultiplier); + } + + return Double.isFinite(scale) && scale > 0.0 ? scale : 0.0; + } + + public static int ticksPerGain(MobEntity mob, float multiplier, float drift, boolean nearAnimation, double nearAnimationMultiplier) { + double scale = passiveGainScale(mob, multiplier, drift, nearAnimation, nearAnimationMultiplier); + return scale <= 0.0 ? 120 : Math.max(1, (int)Math.round(120.0 / scale)); + } + + public static float randomGainMultiplier(ServerWorld world) { + float a = world.getRandom().nextFloat(); + float b = world.getRandom().nextFloat(); + float tri = (a + b) * 0.5F; + return 0.4F + tri * 0.6F; + } + + public static float randomGainDrift(ServerWorld world) { + return 0.9F + world.getRandom().nextFloat() * 0.2F; + } + + public static boolean hasGenericAnimationSupport(EntityType type, boolean baby) { + Identifier entityTypeId = Registries.ENTITY_TYPE.getId(type); + return AnimationDefinitions.hasEnabledGenericAnimationForEntityType(entityTypeId, baby); + } + + public static boolean isEnergyAttackStabilized(Entity entity) { + return entity != null && entity.getCommandTags().contains("non.energy_stabilized"); + } + + public static boolean bypassesEntityEntityRoll(Entity self, Entity candidate) { + return self != null && candidate != null + ? self instanceof HorseEntity && candidate.getType() == NeedsOfNature.HORSE_LIQUID_COLLECTOR_ENTITY_TYPE + || self.getType() == NeedsOfNature.HORSE_LIQUID_COLLECTOR_ENTITY_TYPE && candidate instanceof HorseEntity + : false; + } + + public static boolean passesSameGenderRoll(ServerWorld world, Entity debugSource, List actors) { + if (world == null) { + return true; + } + + int group = resolveGenderGroup(actors); + int percent; + String label; + if (group == 1) { + percent = NeedsOfNature.getConfig().getMaleMaleChancePercent(); + label = "male+male"; + } else if (group == 2) { + percent = NeedsOfNature.getConfig().getFemaleFemaleChancePercent(); + label = "female+female"; + } else { + percent = 100; + label = "mixed"; + } + + return rollPercent(world, debugSource, percent, label); + } + + public static int resolveMultiActorJoinChancePercent(Entity joiningEntity) { + Integer profileChance = NonEntityProfiles.resolve(joiningEntity).multiActorJoinChancePercent(); + return profileChance == null ? NeedsOfNature.getConfig().getMultiActorJoinChancePercent() : Math.max(0, Math.min(100, profileChance)); + } + + public static boolean passesMultiActorJoinRoll(ServerWorld world, Entity debugSource, int totalActors, int chancePercent) { + return world != null && totalActors > 2 ? rollPercent(world, debugSource, chancePercent, "join 3+ actors (total=" + totalActors + ")") : true; + } + + public static boolean passesEntityEntityRoll(ServerWorld world, Entity debugSource) { + if (world == null) { + return true; + } + + int percent = NeedsOfNature.getConfig().getEntityEntityChancePercent(); + return rollPercent(world, debugSource, percent, "entity+entity"); + } + + public static void appendCooldownMapDebug( + List lines, + ServerWorld world, + long nowTick, + String label, + Map cooldowns, + Map bypassableCooldowns, + boolean includeBypassState + ) { + if (lines != null && world != null && cooldowns != null && !cooldowns.isEmpty()) { + int shown = 0; + + for (Entry entry : cooldowns.entrySet()) { + UUID partnerId = entry.getKey(); + long remaining = entry.getValue() - nowTick; + if (partnerId != null && remaining > 0L) { + String suffix = ""; + if (includeBypassState && bypassableCooldowns != null && bypassableCooldowns.containsKey(partnerId)) { + suffix = " (bypass: close+LOS)"; + } + + lines.add(label + ": " + debugEntityName(world, partnerId) + " " + NeedsOfNature.formatTicksForDebug(remaining) + suffix); + if (++shown >= 5) { + int extra = cooldowns.size() - shown; + if (extra > 0) { + lines.add(label + ": +" + extra + " more"); + } + break; + } + } + } + } + } + + public static String debugEntityName(ServerWorld world, UUID uuid) { + if (uuid == null) { + return "unknown"; + } + + Entity entity = world.getEntity(uuid); + if (entity != null) { + return entity.getDisplayName().getString(); + } + + String raw = uuid.toString(); + return raw.length() <= 8 ? raw : raw.substring(0, 8); + } + + private static int resolveGenderGroup(List actors) { + int group = 0; + if (actors != null && !actors.isEmpty()) { + for (Entity actor : actors) { + if (actor == null) { + return 0; + } + + boolean hasMale = actor.getCommandTags().contains("gender.male"); + boolean hasFemale = actor.getCommandTags().contains("gender.female"); + int actorGroup; + if (hasMale && !hasFemale) { + actorGroup = 1; + } else { + if (!hasFemale || hasMale) { + return 0; + } + + actorGroup = 2; + } + + if (group == 0) { + group = actorGroup; + } else if (group != actorGroup) { + return 0; + } + } + + return group; + } else { + return 0; + } + } + + private static boolean rollPercent(ServerWorld world, Entity debugSource, int percent, String label) { + if (percent >= 100) { + return true; + } + + if (percent <= 0) { + return false; + } + + boolean success = world.getRandom().nextInt(100) < percent; + sendRollDebug(world, debugSource, label, percent, success); + return success; + } + + private static void sendRollDebug(ServerWorld world, Entity debugSource, String label, int percent, boolean success) { + if (world != null && debugSource != null) { + String result = success ? "success" : "fail"; + NeedsOfNature.sendDebugChatToNearby( + world, debugSource, 20.0, NonDebugChatCategory.INFO, Text.translatable("debug.needsofnature.roll_result", new Object[]{label, percent, result}) + ); + } + } +} diff --git a/src/main/java/com/nonid/NonMobGatherEligibility.java b/src/main/java/com/nonid/NonMobGatherEligibility.java new file mode 100644 index 0000000..eb43b50 --- /dev/null +++ b/src/main/java/com/nonid/NonMobGatherEligibility.java @@ -0,0 +1,36 @@ +package com.nonid; + +import com.nonid.entity.HorseLiquidCollectorEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.passive.CamelEntity; +import net.minecraft.entity.passive.FoxEntity; +import net.minecraft.entity.passive.TameableEntity; + +public final class NonMobGatherEligibility { + private NonMobGatherEligibility() { + } + + public static boolean canInitiateAutoGather(MobEntity mob) { + if (mob == null || !mob.isAlive() || mob.isRemoved()) { + return false; + } + + if (!mob.isAiDisabled() && !mob.isSleeping()) { + if (mob instanceof TameableEntity tameable && (tameable.isSitting() || tameable.isInSittingPose())) { + return false; + } else { + return mob instanceof FoxEntity fox && (fox.isSitting() || fox.isSleeping()) ? false : !(mob instanceof CamelEntity camel && camel.isSitting()); + } + } else { + return false; + } + } + + public static boolean canBeAutoGatherTarget(MobEntity mob) { + if (mob == null || !mob.isAlive() || mob.isRemoved()) { + return false; + } else { + return mob instanceof HorseLiquidCollectorEntity ? true : canInitiateAutoGather(mob); + } + } +} diff --git a/src/main/java/com/nonid/NonPlayerAttackSystem.java b/src/main/java/com/nonid/NonPlayerAttackSystem.java new file mode 100644 index 0000000..37fad88 --- /dev/null +++ b/src/main/java/com/nonid/NonPlayerAttackSystem.java @@ -0,0 +1,717 @@ +package com.nonid; + +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.server.AnimationStartContext; +import com.nonid.internal.animation.server.ServerAnimationController; +import com.nonid.network.AttackStateS2CPayload; +import com.nonid.network.PlayerAttackInputC2SPayload; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDamage; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AllowDamage; +import net.fabricmc.fabric.api.event.player.AttackEntityCallback; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Disconnect; +import net.minecraft.entity.Entity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Items; +import net.minecraft.registry.RegistryKey; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +final class NonPlayerAttackSystem { + private static final int INPUT_LEFT = 1; + private static final int INPUT_RIGHT = 2; + private static final long INPUT_MIN_INTERVAL_TICKS = 1L; + private static final float PVP_MASH_RANDOM_MIN = 0.8F; + private static final float PVP_MASH_RANDOM_MAX = 1.2F; + private static final Map CONTESTS = new ConcurrentHashMap<>(); + private static final Map DEFEATED_OWNERSHIP = new ConcurrentHashMap<>(); + private static final Map FINISH_ATTEMPTS = new ConcurrentHashMap<>(); + private static final ThreadLocal VANILLA_PVP_BYPASS = new ThreadLocal<>(); + + private NonPlayerAttackSystem() { + } + + static void register() { + AttackEntityCallback.EVENT.register((AttackEntityCallback)(player, world, hand, entity, hitResult) -> { + if (!(world instanceof ServerWorld serverWorld && player instanceof ServerPlayerEntity attacker && entity instanceof ServerPlayerEntity victim)) { + return ActionResult.PASS; + } else if (hand == Hand.MAIN_HAND && attacker.getMainHandStack().isEmpty()) { + return (ActionResult)(tryStartPlayerAttack(serverWorld, attacker, victim) ? ActionResult.SUCCESS : ActionResult.PASS); + } else { + return ActionResult.PASS; + } + }); + ServerPlayNetworking.registerGlobalReceiver( + PlayerAttackInputC2SPayload.ID, (payload, context) -> context.server().execute(() -> handleInput(context.player(), payload)) + ); + ServerLivingEntityEvents.ALLOW_DAMAGE + .register( + (AllowDamage)(entity, source, amount) -> { + if (entity instanceof ServerPlayerEntity victim && victim.getEntityWorld() instanceof ServerWorld world) { + NonPlayerAttackSystem.DefeatedOwnership ownership = validOwnership(world, victim.getUuid()); + if (ownership == null) { + return true; + } else { + ServerPlayerEntity attributed = resolveAttributedPlayer(source); + if (attributed != null && ownership.ownerUuid().equals(attributed.getUuid())) { + FINISH_ATTEMPTS.put( + victim.getUuid(), + new NonPlayerAttackSystem.FinishAttempt( + world.getRegistryKey(), attributed.getUuid(), ownership.instanceId(), countReadyTotems(victim), world.getTime() + ) + ); + return true; + } else { + return false; + } + } + } else { + return true; + } + } + ); + ServerLivingEntityEvents.AFTER_DAMAGE.register((AfterDamage)(entity, source, baseDamageTaken, damageTaken, blocked) -> { + if (entity instanceof ServerPlayerEntity victim && victim.getEntityWorld() instanceof ServerWorld world) { + NonPlayerAttackSystem.FinishAttempt attempt = FINISH_ATTEMPTS.remove(victim.getUuid()); + if (attempt != null && attempt.worldKey().equals(world.getRegistryKey()) && !blocked) { + ServerPlayerEntity attributed = resolveAttributedPlayer(source); + if (attributed != null && attempt.ownerUuid().equals(attributed.getUuid())) { + if (victim.isAlive() && countReadyTotems(victim) < attempt.totemsBefore()) { + AnimationService.stopAnimation(world, attempt.instanceId()); + } + } + } + } + }); + ServerPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, server) -> server.execute(() -> clearForPlayer(handler.player))); + } + + private static boolean tryStartPlayerAttack(ServerWorld world, ServerPlayerEntity attacker, ServerPlayerEntity victim) { + if (world != null && attacker != null && victim != null) { + if (attacker == victim || attacker.getEntityWorld() != world || victim.getEntityWorld() != world) { + return false; + } else if (!attacker.isAlive() || attacker.isRemoved() || attacker.isSpectator()) { + return false; + } else if (!victim.isAlive() || victim.isRemoved() || victim.isSpectator()) { + return false; + } else if (isOwnerFinishingDamage(world, victim, attacker)) { + return false; + } else if (victim.isCreative() && !NeedsOfNature.getConfig().canAttackCreativePlayers()) { + return false; + } else if (!(attacker instanceof EnergyHolder holder && holder.getEnergy() >= 200)) { + return false; + } else { + if (!NonPlayerInteractionSystem.areAttacksAllowed(victim, attacker)) { + return false; + } + + if (AnimationService.isActorPendingOrActive(world, attacker.getUuid()) || AnimationService.isActorPendingOrActive(world, victim.getUuid())) { + return false; + } + + if (!NonAttackSystem.hasAnimationStartProtection(world, attacker) && !NonAttackSystem.hasAnimationStartProtection(world, victim)) { + NonPlayerAttackSystem.AttackSelection selection = selectAttack(world, attacker, victim, true, true); + if (selection.match() == null) { + return selection.blockedByProtector(); + } + + AnimationService.MatchedAnimation match = selection.match(); + List actors = sortedActors(attacker, victim); + NonGatherSystem.cancelForActors(world, List.of(attacker.getUuid(), victim.getUuid())); + UUID instanceId = NeedsOfNature.startAnimationNowWithContext( + world, + match.animationId(), + actors, + match.actorKeys(), + match.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + victim, + buildPlayerAttackContext(victim.getUuid(), attacker.getUuid(), Set.of(attacker.getUuid())), + attacker, + false, + true + ); + if (instanceId != null) { + NeedsOfNature.logRuntime( + "[NoN] Player attack started: attacker={} victim={} animation={} instance={}", + attacker.getName().getString(), + victim.getName().getString(), + match.animationId(), + instanceId + ); + } + + return true; + } else { + return false; + } + } + } else { + return false; + } + } + + @Nullable + static AnimationService.MatchedAnimation findViableAttack(ServerWorld world, ServerPlayerEntity attacker, ServerPlayerEntity victim) { + NonPlayerAttackSystem.AttackSelection selection = selectAttack(world, attacker, victim, false, false); + return selection.match(); + } + + private static NonPlayerAttackSystem.AttackSelection selectAttack( + ServerWorld world, ServerPlayerEntity attacker, ServerPlayerEntity victim, boolean requireStartEligibility, boolean consumeProtector + ) { + List actors = sortedActors(attacker, victim); + Set excluded = new LinkedHashSet<>(); + boolean blockedByProtector = false; + boolean protectorConsumed = false; + + while (true) { + AnimationService.MatchedAnimation match = NonAnimationMatcher.findAutomaticAttack( + world, + actors, + Set.of(), + victim, + requireStartEligibility, + attacker, + excluded, + (candidateWorld, definitionx, candidateActors) -> NonPlayerInteractionSystem.isPlayerInteractionDefinition(definitionx), + null + ); + if (match == null) { + return new NonPlayerAttackSystem.AttackSelection(null, blockedByProtector); + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(match.animationId()); + if (definition == null) { + excluded.add(match.animationId()); + } else { + if (!NeedsOfNature.isProtectedByAccessory(world, definition, actors)) { + return new NonPlayerAttackSystem.AttackSelection(match, blockedByProtector); + } + + blockedByProtector = true; + if (consumeProtector && !protectorConsumed) { + protectorConsumed = NeedsOfNature.consumeProtectorForAnimation(world, definition, actors); + } + + excluded.add(match.animationId()); + } + } + } + + static AnimationStartContext buildPlayerAttackContext(UUID victimUuid, UUID ownerUuid, Set attackerUuids) { + return AnimationStartContext.empty() + .withSessionData(NonAnimationSessionData.playerAttack(new NonAnimationSessionData.PlayerAttack(victimUuid, ownerUuid, ownerUuid, attackerUuids))); + } + + static AnimationStartContext prepareDefeatedJoinContext( + ServerWorld world, List existingActorUuids, ServerPlayerEntity requester, AnimationStartContext sourceContext + ) { + AnimationStartContext context = sourceContext == null ? AnimationStartContext.empty() : sourceContext; + NonAnimationSessionData.PlayerAttack existing = playerAttack(context); + UUID victimUuid = existing == null ? firstPlayerUuid(world, existingActorUuids) : existing.victimUuid(); + if (victimUuid == null) { + return context; + } + + UUID finishOwnerUuid = existing == null ? requester.getUuid() : existing.finishOwnerUuid(); + LinkedHashSet attackers = new LinkedHashSet<>(); + if (existing != null) { + attackers.addAll(existing.attackerUuids()); + } + + attackers.add(requester.getUuid()); + return context.withSessionData( + NonAnimationSessionData.playerAttack(new NonAnimationSessionData.PlayerAttack(victimUuid, finishOwnerUuid, requester.getUuid(), attackers)) + ); + } + + static AnimationStartContext defeatedTailContext(AnimationStartContext sourceContext, UUID victimUuid) { + AnimationStartContext context = sourceContext == null ? AnimationStartContext.empty() : sourceContext; + NonAnimationSessionData.PlayerAttack attack = playerAttack(context); + return attack == null + ? context.withSessionData(NonAnimationSessionData.attack()) + : context.withSessionData(NonAnimationSessionData.playerAttack(attack.forDefeatedTail(victimUuid))); + } + + static boolean isPlayerAttackContext(@Nullable AnimationStartContext context) { + return playerAttack(context) != null; + } + + @Nullable + static ServerPlayerEntity resolveAttackVictim(ServerWorld world, UUID instanceId, List actorUuids) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + NonAnimationSessionData.PlayerAttack attack = snapshot == null ? null : playerAttack(snapshot.startContext()); + if (attack != null && actorUuids != null && actorUuids.contains(attack.victimUuid())) { + ServerPlayerEntity victim = world.getServer().getPlayerManager().getPlayer(attack.victimUuid()); + if (victim != null && victim.getEntityWorld() == world) { + return victim; + } + } + + if (actorUuids == null) { + return null; + } + + for (UUID actorUuid : actorUuids) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(actorUuid); + if (player != null && player.getEntityWorld() == world) { + return player; + } + } + + return null; + } + + static void onAnimationStarted( + ServerWorld world, UUID instanceId, List actorUuids, @Nullable NonAnimationSessionData.PlayerAttack attack, long startTick, boolean defeated + ) { + if (attack != null && actorUuids != null && actorUuids.contains(attack.victimUuid())) { + Set actorSet = Set.copyOf(actorUuids); + LinkedHashSet activeAttackers = new LinkedHashSet<>(); + + for (UUID attackerUuid : attack.attackerUuids()) { + if (actorSet.contains(attackerUuid)) { + ServerPlayerEntity attacker = world.getServer().getPlayerManager().getPlayer(attackerUuid); + if (attacker != null && attacker.getEntityWorld() == world) { + activeAttackers.add(attackerUuid); + } + } + } + + if (defeated) { + DEFEATED_OWNERSHIP.put( + attack.victimUuid(), + new NonPlayerAttackSystem.DefeatedOwnership(world.getRegistryKey(), attack.victimUuid(), attack.finishOwnerUuid(), instanceId) + ); + } + + if (!activeAttackers.isEmpty()) { + NonPlayerAttackSystem.Contest contest = new NonPlayerAttackSystem.Contest( + world.getRegistryKey(), instanceId, attack.victimUuid(), attack.finishOwnerUuid(), attack.initiatorUuid(), activeAttackers, startTick, defeated + ); + CONTESTS.put(instanceId, contest); + } + } + } + + static void onAnimationStopped(ServerWorld world, UUID instanceId) { + NonPlayerAttackSystem.Contest contest = CONTESTS.remove(instanceId); + if (contest != null) { + FINISH_ATTEMPTS.remove(contest.victimUuid); + } + + DEFEATED_OWNERSHIP.entrySet().removeIf(entry -> { + NonPlayerAttackSystem.DefeatedOwnership ownership = entry.getValue(); + return ownership.instanceId().equals(instanceId) && ownership.worldKey().equals(world.getRegistryKey()); + }); + } + + static boolean syncEscapeControl(ServerWorld world, UUID instanceId, boolean escapable, boolean defeated) { + NonPlayerAttackSystem.Contest contest = CONTESTS.get(instanceId); + if (contest != null && contest.worldKey.equals(world.getRegistryKey())) { + contest.escapable = escapable; + contest.defeated = defeated; + broadcastState(world, contest); + return true; + } else { + return false; + } + } + + static boolean isContest(UUID instanceId) { + return instanceId != null && CONTESTS.containsKey(instanceId); + } + + static List getContestDebugLines(ServerPlayerEntity target) { + if (target != null && target.getEntityWorld() instanceof ServerWorld world) { + NonPlayerAttackSystem.Contest contest = findContestForPlayer(world, target.getUuid()); + if (contest == null) { + return List.of(); + } else { + ServerPlayerEntity victim = world.getServer().getPlayerManager().getPlayer(contest.victimUuid); + if (victim != null && victim.getEntityWorld() == world) { + List lines = new ArrayList<>(); + String role = contest.victimUuid.equals(target.getUuid()) ? "defender" : "attacker"; + lines.add( + String.format( + Locale.ROOT, "PvP contest %s | role %s | progress %.1f%%", contest.instanceId.toString().substring(0, 8), role, contest.progress * 100.0F + ) + ); + lines.add(formatContestParticipant(world, contest, victim, true)); + contest.activeAttackerUuids + .stream() + .sorted(Comparator.comparing(UUID::toString)) + .map(uuid -> world.getServer().getPlayerManager().getPlayer(uuid)) + .filter(player -> player != null && player.getEntityWorld() == world) + .map(player -> formatContestParticipant(world, contest, player, false)) + .forEach(lines::add); + return List.copyOf(lines); + } else { + return List.of(); + } + } + } else { + return List.of(); + } + } + + @Nullable + private static NonPlayerAttackSystem.Contest findContestForPlayer(ServerWorld world, UUID playerUuid) { + for (NonPlayerAttackSystem.Contest contest : CONTESTS.values()) { + if (contest.worldKey.equals(world.getRegistryKey()) && (contest.victimUuid.equals(playerUuid) || contest.activeAttackerUuids.contains(playerUuid))) { + return contest; + } + } + + return null; + } + + private static String formatContestParticipant(ServerWorld world, NonPlayerAttackSystem.Contest contest, ServerPlayerEntity participant, boolean defender) { + NonAttackSystem.PvpMashFactors factors = NonAttackSystem.resolveServerPlayerContestStep(world, participant, defender, contest.defeated, contest.startTick); + int energy = 0; + int maxEnergy = 0; + if (participant instanceof EnergyHolder holder) { + energy = holder.getEnergy(); + maxEnergy = holder.getMaxEnergy(); + } + + return String.format( + Locale.ROOT, + "%s %s: %s%.2f-%.2f%%/input | HP %.1f/%.1f | energy %d/%d | health %.2fx, heat %.2fx, healthy %.2fx, ease %.2fx", + defender ? "Defender" : "Attacker", + participant.getName().getString(), + defender ? "+" : "-", + factors.step() * 0.8F * 100.0F, + factors.step() * 1.2F * 100.0F, + participant.getHealth(), + participant.getMaxHealth(), + energy, + maxEnergy, + factors.healthMultiplier(), + factors.heatMultiplier(), + factors.healthyDefenderMultiplier(), + factors.defeatedEaseMultiplier() + ); + } + + static void tick(ServerWorld world) { + long now = world.getTime(); + + for (NonPlayerAttackSystem.Contest contest : List.copyOf(CONTESTS.values())) { + if (contest.worldKey.equals(world.getRegistryKey())) { + if (AnimationService.getCurrentStage(world, contest.instanceId) == null) { + CONTESTS.remove(contest.instanceId, contest); + } else { + ServerPlayerEntity victim = world.getServer().getPlayerManager().getPlayer(contest.victimUuid); + if (victim == null || victim.getEntityWorld() != world || !victim.isAlive() || victim.isRemoved()) { + CONTESTS.remove(contest.instanceId, contest); + } + } + } + } + + DEFEATED_OWNERSHIP.entrySet().removeIf(entry -> { + NonPlayerAttackSystem.DefeatedOwnership ownership = entry.getValue(); + return ownership.worldKey().equals(world.getRegistryKey()) && AnimationService.getCurrentStage(world, ownership.instanceId()) == null; + }); + FINISH_ATTEMPTS.entrySet().removeIf(entry -> { + NonPlayerAttackSystem.FinishAttempt attempt = entry.getValue(); + return attempt.worldKey().equals(world.getRegistryKey()) && now - attempt.tick() > 1L; + }); + } + + private static void handleInput(ServerPlayerEntity player, PlayerAttackInputC2SPayload payload) { + if (player != null && payload != null && player.getEntityWorld() instanceof ServerWorld world) { + NonPlayerAttackSystem.Contest contest = CONTESTS.get(payload.instanceId()); + if (contest != null && !contest.resolving && contest.escapable) { + if (contest.worldKey.equals(world.getRegistryKey())) { + boolean defender = contest.victimUuid.equals(player.getUuid()); + boolean attacker = contest.activeAttackerUuids.contains(player.getUuid()); + if (defender || attacker) { + int key = payload.keyId(); + if (key == 1 || key == 2) { + long now = world.getTime(); + long lastTick = contest.lastInputTicks.getOrDefault(player.getUuid(), Long.MIN_VALUE); + if (lastTick == Long.MIN_VALUE || now - lastTick >= 1L) { + int lastKey = contest.lastKeys.getOrDefault(player.getUuid(), 0); + if (lastKey != key) { + contest.lastInputTicks.put(player.getUuid(), now); + contest.lastKeys.put(player.getUuid(), key); + ServerPlayerEntity victim = world.getServer().getPlayerManager().getPlayer(contest.victimUuid); + if (victim != null && victim.getEntityWorld() == world && victim.isAlive()) { + NonAttackSystem.PvpMashFactors factors = NonAttackSystem.resolveServerPlayerContestStep( + world, player, defender, contest.defeated, contest.startTick + ); + float randomMultiplier = 0.8F + world.getRandom().nextFloat() * 0.40000004F; + float step = factors.step() * randomMultiplier; + contest.progress = clampProgress(contest.progress + (defender ? step : -step)); + broadcastState(world, contest); + if (contest.progress >= 1.0F) { + contest.resolving = true; + NeedsOfNature.completeAttackEscape(world, victim, contest.instanceId, contest.initiatorUuid); + } + } + } + } + } + } + } + } + } + } + + private static void broadcastState(ServerWorld world, NonPlayerAttackSystem.Contest contest) { + AttackStateS2CPayload.EscapeProfile profile = contest.defeated + ? AttackStateS2CPayload.EscapeProfile.DEFEATED + : AttackStateS2CPayload.EscapeProfile.NORMAL; + ServerPlayerEntity victim = world.getServer().getPlayerManager().getPlayer(contest.victimUuid); + if (victim != null && victim.getEntityWorld() == world) { + ServerPlayNetworking.send( + victim, new AttackStateS2CPayload(contest.instanceId, true, contest.escapable, profile, AttackStateS2CPayload.PvpRole.DEFENDER, contest.progress) + ); + } + + for (UUID attackerUuid : contest.activeAttackerUuids) { + ServerPlayerEntity attacker = world.getServer().getPlayerManager().getPlayer(attackerUuid); + if (attacker != null && attacker.getEntityWorld() == world) { + ServerPlayNetworking.send( + attacker, + new AttackStateS2CPayload(contest.instanceId, true, contest.escapable, profile, AttackStateS2CPayload.PvpRole.ATTACKER, contest.progress) + ); + } + } + } + + static boolean isOwnerFinishingDamage(ServerWorld world, ServerPlayerEntity victim, @Nullable DamageSource source) { + ServerPlayerEntity attacker = resolveAttributedPlayer(source); + return attacker != null && isOwnerFinishingDamage(world, victim, attacker); + } + + static boolean shouldBypassVanillaPvpCheck(ServerPlayerEntity victim, PlayerEntity attacker) { + if (victim != null && attacker != null && victim.getEntityWorld() instanceof ServerWorld world) { + NonPlayerAttackSystem.DamageBypass bypass = VANILLA_PVP_BYPASS.get(); + if (bypass != null && bypass.victimUuid().equals(victim.getUuid()) && bypass.attackerUuid().equals(attacker.getUuid())) { + return true; + } + + NonPlayerAttackSystem.DefeatedOwnership ownership = validOwnership(world, victim.getUuid()); + return ownership != null && ownership.ownerUuid().equals(attacker.getUuid()); + } else { + return false; + } + } + + static void applyPlayerAttributedEscapeDamage(ServerWorld world, ServerPlayerEntity victim, ServerPlayerEntity attacker, float amount) { + NonPlayerAttackSystem.DamageBypass previous = VANILLA_PVP_BYPASS.get(); + VANILLA_PVP_BYPASS.set(new NonPlayerAttackSystem.DamageBypass(victim.getUuid(), attacker.getUuid())); + + try { + victim.damage(world, victim.getDamageSources().playerAttack(attacker), amount); + } finally { + if (previous == null) { + VANILLA_PVP_BYPASS.remove(); + } else { + VANILLA_PVP_BYPASS.set(previous); + } + } + } + + private static boolean isOwnerFinishingDamage(ServerWorld world, ServerPlayerEntity victim, ServerPlayerEntity attacker) { + NonPlayerAttackSystem.DefeatedOwnership ownership = validOwnership(world, victim.getUuid()); + return ownership != null && ownership.ownerUuid().equals(attacker.getUuid()); + } + + static void onOwnerFinishingDeath(ServerWorld world, ServerPlayerEntity victim) { + if (world != null && victim != null) { + NonPlayerAttackSystem.DefeatedOwnership ownership = DEFEATED_OWNERSHIP.get(victim.getUuid()); + if (ownership != null && ownership.worldKey().equals(world.getRegistryKey())) { + FINISH_ATTEMPTS.remove(victim.getUuid()); + } + } + } + + @Nullable + static ServerPlayerEntity resolveAttributedPlayer(@Nullable DamageSource source) { + if (source == null) { + return null; + } else if (source.getAttacker() instanceof ServerPlayerEntity player) { + return player; + } else { + return source.getSource() instanceof ServerPlayerEntity player ? player : null; + } + } + + static NonAnimationDamageBehavior defeatedDamageBehavior(AnimationStartContext context) { + return isPlayerAttackContext(context) ? NonAnimationDamageBehavior.IGNORE_DAMAGE : NonAnimationDamageBehavior.STOP_ON_DAMAGE; + } + + static void clearForRespawn(ServerPlayerEntity player) { + clearForPlayer(player); + } + + private static void clearForPlayer(ServerPlayerEntity player) { + if (player != null && player.getEntityWorld() instanceof ServerWorld world) { + UUID var6 = player.getUuid(); + LinkedHashSet stopInstances = new LinkedHashSet<>(); + + for (NonPlayerAttackSystem.Contest contest : List.copyOf(CONTESTS.values())) { + if (contest.worldKey.equals(world.getRegistryKey()) + && ( + contest.victimUuid.equals(var6) + || contest.finishOwnerUuid.equals(var6) + || contest.initiatorUuid.equals(var6) + || contest.activeAttackerUuids.contains(var6) + )) { + stopInstances.add(contest.instanceId); + } + } + + for (NonPlayerAttackSystem.DefeatedOwnership ownership : List.copyOf(DEFEATED_OWNERSHIP.values())) { + if (ownership.worldKey().equals(world.getRegistryKey()) && (ownership.victimUuid().equals(var6) || ownership.ownerUuid().equals(var6))) { + stopInstances.add(ownership.instanceId()); + } + } + + for (UUID instanceId : stopInstances) { + AnimationService.stopAnimation(world, instanceId); + } + + CONTESTS.entrySet().removeIf(entry -> stopInstances.contains(entry.getKey())); + DEFEATED_OWNERSHIP.entrySet().removeIf(entry -> entry.getKey().equals(var6) || entry.getValue().ownerUuid().equals(var6)); + FINISH_ATTEMPTS.remove(var6); + } + } + + static void clearRuntimeState() { + CONTESTS.clear(); + DEFEATED_OWNERSHIP.clear(); + FINISH_ATTEMPTS.clear(); + } + + @Nullable + private static NonPlayerAttackSystem.DefeatedOwnership validOwnership(ServerWorld world, UUID victimUuid) { + NonPlayerAttackSystem.DefeatedOwnership ownership = DEFEATED_OWNERSHIP.get(victimUuid); + if (ownership == null || !ownership.worldKey().equals(world.getRegistryKey())) { + return null; + } else if (AnimationService.getCurrentStage(world, ownership.instanceId()) == null) { + DEFEATED_OWNERSHIP.remove(victimUuid, ownership); + return null; + } else { + return ownership; + } + } + + @Nullable + static NonAnimationSessionData.PlayerAttack playerAttack(@Nullable AnimationStartContext context) { + return context != null && context.sessionData() instanceof NonAnimationSessionData data ? data.playerAttack() : null; + } + + @Nullable + private static UUID firstPlayerUuid(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null) { + for (UUID actorUuid : actorUuids) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(actorUuid); + if (player != null && player.getEntityWorld() == world) { + return player.getUuid(); + } + } + + return null; + } else { + return null; + } + } + + private static List sortedActors(Entity first, Entity second) { + ArrayList actors = new ArrayList<>(List.of(first, second)); + actors.sort(Comparator.comparingInt(Entity::getId)); + return List.copyOf(actors); + } + + private static int countReadyTotems(ServerPlayerEntity player) { + int count = 0; + if (player.getMainHandStack().isOf(Items.TOTEM_OF_UNDYING)) { + count += player.getMainHandStack().getCount(); + } + + if (player.getOffHandStack().isOf(Items.TOTEM_OF_UNDYING)) { + count += player.getOffHandStack().getCount(); + } + + return count; + } + + private static float clampProgress(float progress) { + return !Float.isFinite(progress) ? 0.0F : Math.max(0.0F, Math.min(1.0F, progress)); + } + + private record AttackSelection(@Nullable AnimationService.MatchedAnimation match, boolean blockedByProtector) { + } + + private static final class Contest { + final RegistryKey worldKey; + final UUID instanceId; + final UUID victimUuid; + final UUID finishOwnerUuid; + final UUID initiatorUuid; + final Set activeAttackerUuids; + final long startTick; + final Map lastKeys = new HashMap<>(); + final Map lastInputTicks = new HashMap<>(); + float progress; + boolean escapable; + boolean defeated; + boolean resolving; + + Contest( + RegistryKey worldKey, + UUID instanceId, + UUID victimUuid, + UUID finishOwnerUuid, + UUID initiatorUuid, + Set activeAttackerUuids, + long startTick, + boolean defeated + ) { + this.worldKey = worldKey; + this.instanceId = instanceId; + this.victimUuid = victimUuid; + this.finishOwnerUuid = finishOwnerUuid; + this.initiatorUuid = initiatorUuid; + this.activeAttackerUuids = Set.copyOf(activeAttackerUuids); + this.startTick = startTick; + this.defeated = defeated; + } + } + + private record DamageBypass(UUID victimUuid, UUID attackerUuid) { + } + + private record DefeatedOwnership(RegistryKey worldKey, UUID victimUuid, UUID ownerUuid, UUID instanceId) { + } + + private record FinishAttempt(RegistryKey worldKey, UUID ownerUuid, UUID instanceId, int totemsBefore, long tick) { + } +} diff --git a/src/main/java/com/nonid/NonPlayerAvoidanceState.java b/src/main/java/com/nonid/NonPlayerAvoidanceState.java new file mode 100644 index 0000000..c291645 --- /dev/null +++ b/src/main/java/com/nonid/NonPlayerAvoidanceState.java @@ -0,0 +1,7 @@ +package com.nonid; + +import net.minecraft.server.network.ServerPlayerEntity; + +public interface NonPlayerAvoidanceState { + boolean shouldSuppressPlayerAvoidance(ServerPlayerEntity var1); +} diff --git a/src/main/java/com/nonid/NonPlayerInteractionProfiles.java b/src/main/java/com/nonid/NonPlayerInteractionProfiles.java new file mode 100644 index 0000000..5ea9248 --- /dev/null +++ b/src/main/java/com/nonid/NonPlayerInteractionProfiles.java @@ -0,0 +1,383 @@ +package com.nonid; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import net.minecraft.datafixer.DataFixTypes; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.world.PersistentState; +import net.minecraft.world.PersistentStateType; +import org.jetbrains.annotations.Nullable; + +public final class NonPlayerInteractionProfiles extends PersistentState { + private static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance.group( + Codec.unboundedMap(Codec.STRING, NonPlayerInteractionProfiles.PlayerProfiles.CODEC) + .optionalFieldOf("profiles", Map.of()) + .forGetter(state -> state.profilesByOwner), + Codec.unboundedMap(Codec.STRING, Codec.STRING).optionalFieldOf("known_players", Map.of()).forGetter(state -> state.knownPlayerNames) + ) + .apply(instance, NonPlayerInteractionProfiles::new) + ); + private static final PersistentStateType TYPE = new PersistentStateType( + "needsofnature_player_interactions", NonPlayerInteractionProfiles::new, CODEC, DataFixTypes.SAVED_DATA_COMMAND_STORAGE + ); + private final Map profilesByOwner; + private final Map knownPlayerNames; + + private NonPlayerInteractionProfiles() { + this(Map.of(), Map.of()); + } + + private NonPlayerInteractionProfiles(Map profilesByOwner, Map knownPlayerNames) { + this.profilesByOwner = new LinkedHashMap<>(profilesByOwner == null ? Map.of() : profilesByOwner); + this.knownPlayerNames = new LinkedHashMap<>(knownPlayerNames == null ? Map.of() : knownPlayerNames); + } + + public static NonPlayerInteractionProfiles get(ServerWorld world) { + if (world != null && world.getServer() != null && world.getServer().getOverworld() != null) { + return (NonPlayerInteractionProfiles)world.getServer().getOverworld().getPersistentStateManager().getOrCreate(TYPE); + } else { + throw new IllegalArgumentException("A live server world is required"); + } + } + + public synchronized void recordKnownPlayer(ServerPlayerEntity player) { + if (player != null) { + this.recordKnownPlayer(player.getUuid(), player.getGameProfile().name()); + } + } + + public synchronized void recordKnownPlayer(UUID uuid, String name) { + if (uuid != null) { + String safeName = sanitizeName(name); + String uuidKey = uuid.toString(); + boolean changed = !safeName.equals(this.knownPlayerNames.put(uuidKey, safeName)); + Map updatedOwners = new LinkedHashMap<>(); + + for (Entry ownerEntry : this.profilesByOwner.entrySet()) { + NonPlayerInteractionProfiles.PlayerProfiles profiles = ownerEntry.getValue(); + NonPlayerInteractionProfiles.ProfileOverride existing = profiles.overrides().get(uuidKey); + if (existing != null && !safeName.equals(existing.name())) { + Map overrides = new LinkedHashMap<>(profiles.overrides()); + overrides.put(uuidKey, new NonPlayerInteractionProfiles.ProfileOverride(safeName, existing.mode())); + updatedOwners.put(ownerEntry.getKey(), new NonPlayerInteractionProfiles.PlayerProfiles(profiles.defaultMode(), overrides)); + } + } + + if (!updatedOwners.isEmpty()) { + this.profilesByOwner.putAll(updatedOwners); + changed = true; + } + + if (changed) { + this.markDirty(); + } + } + } + + @Nullable + public synchronized NonPlayerInteractionProfiles.KnownPlayer findKnownPlayerByName(String name) { + String normalized = sanitizeName(name); + if (normalized.isEmpty()) { + return null; + } + + for (Entry entry : this.knownPlayerNames.entrySet()) { + if (entry.getValue().equalsIgnoreCase(normalized)) { + try { + return new NonPlayerInteractionProfiles.KnownPlayer(UUID.fromString(entry.getKey()), entry.getValue()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + } + + return null; + } + + @Nullable + public synchronized NonPlayerInteractionProfiles.KnownPlayer findKnownPlayer(UUID uuid) { + if (uuid == null) { + return null; + } + + String name = this.knownPlayerNames.get(uuid.toString()); + return name == null ? null : new NonPlayerInteractionProfiles.KnownPlayer(uuid, name); + } + + public synchronized NonPlayerInteractionProfiles.Mode resolveMode(UUID ownerUuid, UUID otherUuid, int allowedMask) { + NonPlayerInteractionProfiles.PlayerProfiles profiles = this.getOrDefault(ownerUuid); + NonPlayerInteractionProfiles.ProfileOverride override = otherUuid == null ? null : profiles.overrides().get(otherUuid.toString()); + return NonPlayerInteractionProfiles.Mode.normalizeAllowed(override == null ? profiles.defaultMode() : override.mode(), allowedMask); + } + + public synchronized NonPlayerInteractionProfiles.Snapshot snapshot(UUID ownerUuid, int allowedMask) { + this.normalizeOwner(ownerUuid, allowedMask); + NonPlayerInteractionProfiles.PlayerProfiles profiles = this.getOrDefault(ownerUuid); + NonPlayerInteractionProfiles.Mode normalizedDefault = NonPlayerInteractionProfiles.Mode.normalizeAllowed(profiles.defaultMode(), allowedMask); + List overrides = new ArrayList<>(); + + for (Entry entry : profiles.overrides().entrySet()) { + try { + UUID uuid = UUID.fromString(entry.getKey()); + NonPlayerInteractionProfiles.ProfileOverride value = entry.getValue(); + overrides.add(new NonPlayerInteractionProfiles.OverrideView(uuid, value.name(), value.mode())); + } catch (IllegalArgumentException var11) { + } + } + + overrides.sort(Comparator.comparing(NonPlayerInteractionProfiles.OverrideView::name, String.CASE_INSENSITIVE_ORDER)); + List known = new ArrayList<>(); + + for (Entry entry : this.knownPlayerNames.entrySet()) { + try { + UUID uuid = UUID.fromString(entry.getKey()); + if (!uuid.equals(ownerUuid)) { + known.add(new NonPlayerInteractionProfiles.KnownPlayer(uuid, entry.getValue())); + } + } catch (IllegalArgumentException var10) { + } + } + + known.sort(Comparator.comparing(NonPlayerInteractionProfiles.KnownPlayer::name, String.CASE_INSENSITIVE_ORDER)); + return new NonPlayerInteractionProfiles.Snapshot(normalizedDefault, List.copyOf(overrides), List.copyOf(known)); + } + + public synchronized void setDefaultMode(UUID ownerUuid, NonPlayerInteractionProfiles.Mode mode, int allowedMask) { + if (ownerUuid != null) { + NonPlayerInteractionProfiles.PlayerProfiles existing = this.getOrDefault(ownerUuid); + this.profilesByOwner + .put( + ownerUuid.toString(), + new NonPlayerInteractionProfiles.PlayerProfiles(NonPlayerInteractionProfiles.Mode.normalizeAllowed(mode, allowedMask), existing.overrides()) + ); + this.markDirty(); + } + } + + public synchronized void setOverride(UUID ownerUuid, UUID otherUuid, String name, NonPlayerInteractionProfiles.Mode mode, int allowedMask) { + if (ownerUuid != null && otherUuid != null && !ownerUuid.equals(otherUuid)) { + NonPlayerInteractionProfiles.PlayerProfiles existing = this.getOrDefault(ownerUuid); + Map overrides = new LinkedHashMap<>(existing.overrides()); + String safeName = sanitizeName(name); + if (safeName.isEmpty()) { + safeName = this.knownPlayerNames.getOrDefault(otherUuid.toString(), otherUuid.toString()); + } + + overrides.put( + otherUuid.toString(), + new NonPlayerInteractionProfiles.ProfileOverride(safeName, NonPlayerInteractionProfiles.Mode.normalizeAllowed(mode, allowedMask)) + ); + this.profilesByOwner.put(ownerUuid.toString(), new NonPlayerInteractionProfiles.PlayerProfiles(existing.defaultMode(), overrides)); + this.knownPlayerNames.put(otherUuid.toString(), safeName); + this.markDirty(); + } + } + + public synchronized void removeOverride(UUID ownerUuid, UUID otherUuid) { + if (ownerUuid != null && otherUuid != null) { + NonPlayerInteractionProfiles.PlayerProfiles existing = this.getOrDefault(ownerUuid); + if (existing.overrides().containsKey(otherUuid.toString())) { + Map overrides = new LinkedHashMap<>(existing.overrides()); + overrides.remove(otherUuid.toString()); + this.profilesByOwner.put(ownerUuid.toString(), new NonPlayerInteractionProfiles.PlayerProfiles(existing.defaultMode(), overrides)); + this.markDirty(); + } + } + } + + public synchronized void normalizeAll(int allowedMask) { + boolean changed = false; + + for (String ownerKey : List.copyOf(this.profilesByOwner.keySet())) { + changed |= this.normalizeOwnerKey(ownerKey, allowedMask); + } + + if (changed) { + this.markDirty(); + } + } + + private void normalizeOwner(UUID ownerUuid, int allowedMask) { + if (ownerUuid != null && this.normalizeOwnerKey(ownerUuid.toString(), allowedMask)) { + this.markDirty(); + } + } + + private boolean normalizeOwnerKey(String ownerKey, int allowedMask) { + NonPlayerInteractionProfiles.PlayerProfiles existing = this.profilesByOwner.get(ownerKey); + if (existing == null) { + return false; + } + + NonPlayerInteractionProfiles.Mode normalizedDefault = NonPlayerInteractionProfiles.Mode.normalizeAllowed(existing.defaultMode(), allowedMask); + Map overrides = new LinkedHashMap<>(); + boolean changed = normalizedDefault != existing.defaultMode(); + + for (Entry entry : existing.overrides().entrySet()) { + NonPlayerInteractionProfiles.ProfileOverride value = entry.getValue(); + NonPlayerInteractionProfiles.Mode normalized = NonPlayerInteractionProfiles.Mode.normalizeAllowed(value.mode(), allowedMask); + overrides.put(entry.getKey(), new NonPlayerInteractionProfiles.ProfileOverride(value.name(), normalized)); + changed |= normalized != value.mode(); + } + + if (changed) { + this.profilesByOwner.put(ownerKey, new NonPlayerInteractionProfiles.PlayerProfiles(normalizedDefault, overrides)); + } + + return changed; + } + + private NonPlayerInteractionProfiles.PlayerProfiles getOrDefault(UUID ownerUuid) { + return ownerUuid == null + ? new NonPlayerInteractionProfiles.PlayerProfiles(NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS, Map.of()) + : this.profilesByOwner + .getOrDefault( + ownerUuid.toString(), new NonPlayerInteractionProfiles.PlayerProfiles(NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS, Map.of()) + ); + } + + public static int normalizeAllowedMask(int mask) { + int normalized = mask & 15; + return normalized == 0 ? 15 : normalized; + } + + private static String sanitizeName(@Nullable String name) { + if (name == null) { + return ""; + } + + String trimmed = name.trim(); + return trimmed.length() <= 64 ? trimmed : trimmed.substring(0, 64); + } + + public record KnownPlayer(UUID uuid, String name) { + } + + public enum Mode { + DENY_ALL("deny_all", NonPlayerInteractionProfiles.RequestPolicy.DENY, false), + REQUESTS_ONLY("requests_only", NonPlayerInteractionProfiles.RequestPolicy.ASK, false), + REQUESTS_AND_ATTACKS("requests_and_attacks", NonPlayerInteractionProfiles.RequestPolicy.ASK, true), + AUTO_ACCEPT("auto_accept", NonPlayerInteractionProfiles.RequestPolicy.AUTO_ACCEPT, true); + + public static final Codec CODEC = Codec.STRING + .xmap(value -> fromId(value, REQUESTS_AND_ATTACKS), NonPlayerInteractionProfiles.Mode::id); + private final String id; + private final NonPlayerInteractionProfiles.RequestPolicy requestPolicy; + private final boolean attacksAllowed; + + Mode(String id, NonPlayerInteractionProfiles.RequestPolicy requestPolicy, boolean attacksAllowed) { + this.id = id; + this.requestPolicy = requestPolicy; + this.attacksAllowed = attacksAllowed; + } + + public String id() { + return this.id; + } + + public NonPlayerInteractionProfiles.RequestPolicy requestPolicy() { + return this.requestPolicy; + } + + public boolean attacksAllowed() { + return this.attacksAllowed; + } + + public int bit() { + return 1 << this.ordinal(); + } + + public static NonPlayerInteractionProfiles.Mode fromId(@Nullable String value, NonPlayerInteractionProfiles.Mode fallback) { + if (value != null) { + String normalized = value.trim().toLowerCase(Locale.ROOT); + + for (NonPlayerInteractionProfiles.Mode mode : values()) { + if (mode.id.equals(normalized)) { + return mode; + } + } + } + + return fallback == null ? REQUESTS_AND_ATTACKS : fallback; + } + + public static NonPlayerInteractionProfiles.Mode leastPermissiveAllowed(int allowedMask) { + int normalized = NonPlayerInteractionProfiles.normalizeAllowedMask(allowedMask); + + for (NonPlayerInteractionProfiles.Mode mode : values()) { + if ((normalized & mode.bit()) != 0) { + return mode; + } + } + + return REQUESTS_AND_ATTACKS; + } + + public static NonPlayerInteractionProfiles.Mode normalizeAllowed(@Nullable NonPlayerInteractionProfiles.Mode mode, int allowedMask) { + NonPlayerInteractionProfiles.Mode resolved = mode == null ? REQUESTS_AND_ATTACKS : mode; + return (NonPlayerInteractionProfiles.normalizeAllowedMask(allowedMask) & resolved.bit()) != 0 ? resolved : leastPermissiveAllowed(allowedMask); + } + } + + public record OverrideView(UUID uuid, String name, NonPlayerInteractionProfiles.Mode mode) { + } + + public record PlayerProfiles(NonPlayerInteractionProfiles.Mode defaultMode, Map overrides) { + private static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance.group( + NonPlayerInteractionProfiles.Mode.CODEC + .optionalFieldOf("default_mode", NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS) + .forGetter(NonPlayerInteractionProfiles.PlayerProfiles::defaultMode), + Codec.unboundedMap(Codec.STRING, NonPlayerInteractionProfiles.ProfileOverride.CODEC) + .optionalFieldOf("overrides", Map.of()) + .forGetter(NonPlayerInteractionProfiles.PlayerProfiles::overrides) + ) + .apply(instance, NonPlayerInteractionProfiles.PlayerProfiles::new) + ); + + public PlayerProfiles { + defaultMode = defaultMode == null ? NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS : defaultMode; + overrides = overrides == null ? Map.of() : Map.copyOf(overrides); + } + } + + public record ProfileOverride(String name, NonPlayerInteractionProfiles.Mode mode) { + private static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance.group( + Codec.STRING.optionalFieldOf("name", "").forGetter(NonPlayerInteractionProfiles.ProfileOverride::name), + NonPlayerInteractionProfiles.Mode.CODEC + .optionalFieldOf("mode", NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS) + .forGetter(NonPlayerInteractionProfiles.ProfileOverride::mode) + ) + .apply(instance, NonPlayerInteractionProfiles.ProfileOverride::new) + ); + + public ProfileOverride { + name = NonPlayerInteractionProfiles.sanitizeName(name); + mode = mode == null ? NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS : mode; + } + } + + public enum RequestPolicy { + DENY, + ASK, + AUTO_ACCEPT; + } + + public record Snapshot( + NonPlayerInteractionProfiles.Mode defaultMode, + List overrides, + List knownPlayers + ) { + } +} diff --git a/src/main/java/com/nonid/NonPlayerInteractionSystem.java b/src/main/java/com/nonid/NonPlayerInteractionSystem.java new file mode 100644 index 0000000..5169502 --- /dev/null +++ b/src/main/java/com/nonid/NonPlayerInteractionSystem.java @@ -0,0 +1,1101 @@ +package com.nonid; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.network.PlayerAnimationCancelC2SPayload; +import com.nonid.network.PlayerAnimationChoiceC2SPayload; +import com.nonid.network.PlayerAnimationConsentC2SPayload; +import com.nonid.network.PlayerAnimationConsentS2CPayload; +import com.nonid.network.PlayerAnimationRequestStatusS2CPayload; +import com.nonid.network.PlayerAnimationSelectionS2CPayload; +import com.nonid.network.PlayerProfileActionC2SPayload; +import com.nonid.network.PlayerProfileSnapshotS2CPayload; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDamage; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStarted; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStopped; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Disconnect; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Join; +import net.minecraft.entity.Entity; +import net.minecraft.registry.RegistryKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +public final class NonPlayerInteractionSystem { + private static final int REQUEST_TIMEOUT_TICKS = 300; + private static final String STATUS_WAITING = "waiting"; + private static final String STATUS_STARTED = "started"; + private static final String STATUS_FAILED = "failed"; + private static final String STATUS_CANCELLED = "cancelled"; + private static final Map SELECTIONS = new HashMap<>(); + private static final Map REQUESTS = new HashMap<>(); + private static final Map SESSION_BY_PLAYER = new HashMap<>(); + @Nullable + private static MinecraftServer activeServer; + + private NonPlayerInteractionSystem() { + } + + public static void register() { + ServerLifecycleEvents.SERVER_STARTED.register((ServerStarted)server -> activeServer = server); + ServerLifecycleEvents.SERVER_STOPPED.register((ServerStopped)server -> { + if (activeServer == server) { + activeServer = null; + } + + SELECTIONS.clear(); + REQUESTS.clear(); + SESSION_BY_PLAYER.clear(); + }); + ServerPlayNetworking.registerGlobalReceiver( + PlayerProfileActionC2SPayload.ID, (payload, context) -> context.server().execute(() -> handleProfileAction(context.player(), payload)) + ); + ServerPlayNetworking.registerGlobalReceiver( + PlayerAnimationChoiceC2SPayload.ID, (payload, context) -> context.server().execute(() -> handleAnimationChoice(context.player(), payload)) + ); + ServerPlayNetworking.registerGlobalReceiver( + PlayerAnimationConsentC2SPayload.ID, (payload, context) -> context.server().execute(() -> handleConsent(context.player(), payload)) + ); + ServerPlayNetworking.registerGlobalReceiver( + PlayerAnimationCancelC2SPayload.ID, (payload, context) -> context.server().execute(() -> handleCancel(context.player(), payload.requestId())) + ); + ServerPlayConnectionEvents.JOIN.register((Join)(handler, sender, server) -> server.execute(() -> { + if (handler.player.getEntityWorld() instanceof ServerWorld world) { + NonPlayerInteractionProfiles var6 = NonPlayerInteractionProfiles.get(world); + + for (ServerPlayerEntity listedPlayer : server.getPlayerManager().getPlayerList()) { + var6.recordKnownPlayer(listedPlayer); + } + + var6.normalizeAll(allowedMask()); + } + })); + ServerPlayConnectionEvents.DISCONNECT + .register( + (Disconnect)(handler, server) -> server.execute(() -> cancelForPlayer(handler.player.getUuid(), "gui.needsofnature.player_request.disconnected")) + ); + ServerLivingEntityEvents.AFTER_DAMAGE.register((AfterDamage)(entity, source, baseDamageTaken, damageTaken, blocked) -> { + if (!blocked && damageTaken > 0.0F && entity instanceof ServerPlayerEntity player) { + cancelForPlayer(player.getUuid(), "gui.needsofnature.player_request.damaged"); + } + }); + } + + public static boolean handleInteraction(ServerWorld world, ServerPlayerEntity requester, Entity clickedEntity) { + if (isUsablePlayer(requester) && world != null && clickedEntity != null && !clickedEntity.isRemoved()) { + if (requester.getEntityWorld() != world || clickedEntity.getEntityWorld() != world) { + return false; + } else if (requester.getUuid().equals(clickedEntity.getUuid())) { + return false; + } else { + UUID activeInstance = NeedsOfNature.findActiveInstanceContainingActor(world, clickedEntity.getUuid()); + if (activeInstance != null) { + return beginJoinRequest(world, requester, clickedEntity, activeInstance); + } else { + return clickedEntity instanceof ServerPlayerEntity target && requester.isSneaking() ? beginDirectSelection(world, requester, target) : false; + } + } + } else { + return false; + } + } + + public static void tick(ServerWorld world) { + if (world != null) { + long now = world.getTime(); + + for (NonPlayerInteractionSystem.SelectionSession selection : List.copyOf(SELECTIONS.values())) { + if (world.getRegistryKey().equals(selection.worldKey())) { + ServerPlayerEntity requester = getPlayer(world, selection.requesterUuid()); + Entity target = resolveEntity(world, selection.targetUuid()); + if (now >= selection.expiresTick()) { + failSelection(selection, "failed", "gui.needsofnature.player_request.timeout"); + } else if (!validInteractionPair(requester, target)) { + failSelection(selection, "cancelled", "gui.needsofnature.player_request.out_of_range"); + } else if (selection.kind() == NonPlayerInteractionSystem.RequestKind.DIRECT && isBusyForDirectRequest(world, requester, target)) { + failSelection(selection, "failed", "gui.needsofnature.player_request.player_busy"); + } else if (selection.kind() == NonPlayerInteractionSystem.RequestKind.JOIN && !isSelectionSourceJoinable(world, selection)) { + failSelection(selection, "failed", "gui.needsofnature.player_request.animation_changed"); + } + } + } + + for (NonPlayerInteractionSystem.PendingRequest request : List.copyOf(REQUESTS.values())) { + if (world.getRegistryKey().equals(request.worldKey())) { + ServerPlayerEntity requester = getPlayer(world, request.requesterUuid()); + Entity target = resolveEntity(world, request.interactionTargetUuid()); + if (now >= request.expiresTick()) { + finish(request, "failed", "gui.needsofnature.player_request.timeout"); + } else if (!validInteractionPair(requester, target)) { + finish(request, "cancelled", "gui.needsofnature.player_request.out_of_range"); + } else if (request.kind() == NonPlayerInteractionSystem.RequestKind.DIRECT && isBusyForDirectRequest(world, requester, target)) { + finish(request, "failed", "gui.needsofnature.player_request.player_busy"); + } else if (request.kind() == NonPlayerInteractionSystem.RequestKind.JOIN + && (request.sourceInstanceUuid() == null || AnimationService.getCurrentStage(world, request.sourceInstanceUuid()) == null)) { + finish(request, "failed", "gui.needsofnature.player_request.animation_changed"); + } + } + } + } + } + + public static void normalizeAndSyncAll(MinecraftServer server) { + if (server != null && server.getOverworld() != null) { + NonPlayerInteractionProfiles profiles = NonPlayerInteractionProfiles.get(server.getOverworld()); + profiles.normalizeAll(allowedMask()); + + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + sendProfileSnapshot(player, ""); + } + } + } + + public static boolean areAttacksAllowed(ServerPlayerEntity target, ServerPlayerEntity otherPlayer) { + if (target != null && otherPlayer != null && target.getEntityWorld() instanceof ServerWorld world) { + NonPlayerInteractionProfiles.Mode mode = NonPlayerInteractionProfiles.get(world).resolveMode(target.getUuid(), otherPlayer.getUuid(), allowedMask()); + return mode.attacksAllowed(); + } else { + return false; + } + } + + private static boolean beginDirectSelection(ServerWorld world, ServerPlayerEntity requester, ServerPlayerEntity target) { + if (!validInteractionPair(requester, target)) { + return false; + } + + if (!hasRequestEnergy(requester)) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.energy_low"); + return true; + } + + if (SESSION_BY_PLAYER.containsKey(requester.getUuid())) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.already_pending"); + return true; + } + + NonPlayerInteractionProfiles.Mode targetMode = resolveMode(world, target, requester); + if (targetMode.requestPolicy() == NonPlayerInteractionProfiles.RequestPolicy.DENY) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.not_accepting"); + return true; + } + + if (!NeedsOfNature.hasActivePlayerAnimation(requester) && !NeedsOfNature.hasActivePlayerAnimation(target)) { + List actors = sortedActors(requester, target); + List actorUuids = actors.stream().map(Entity::getUuid).toList(); + List candidates = enumerateCandidates(world, actors, target, null); + if (candidates.isEmpty()) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.no_match"); + return true; + } else { + UUID requestId = UUID.randomUUID(); + NonPlayerInteractionSystem.SelectionSession session = new NonPlayerInteractionSystem.SelectionSession( + requestId, + NonPlayerInteractionSystem.RequestKind.DIRECT, + world.getRegistryKey(), + requester.getUuid(), + target.getUuid(), + null, + world.getTime() + 300L, + List.copyOf(actorUuids), + List.copyOf(candidates) + ); + SELECTIONS.put(requestId, session); + SESSION_BY_PLAYER.put(requester.getUuid(), requestId); + ServerPlayNetworking.send( + requester, + new PlayerAnimationSelectionS2CPayload( + requestId, target.getUuid(), target.getGameProfile().name(), candidatesJson(session, requester.getUuid()), false + ) + ); + return true; + } + } else { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.player_busy"); + return true; + } + } + + private static boolean beginJoinRequest(ServerWorld world, ServerPlayerEntity requester, Entity clickedEntity, UUID instanceId) { + if (!hasRequestEnergy(requester)) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.energy_low"); + return true; + } else if (SESSION_BY_PLAYER.containsKey(requester.getUuid())) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.already_pending"); + return true; + } else if (AnimationService.isActorPendingOrActive(world, requester.getUuid())) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.player_busy"); + return true; + } else { + boolean defeated = NeedsOfNature.isDefeatedInstance(world, instanceId); + if (!defeated && !NeedsOfNature.isVoluntaryJoinableInstance(world, instanceId)) { + return false; + } else { + List actors = entitiesForJoin(world, instanceId, requester); + if (actors.isEmpty()) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.no_match"); + return true; + } else { + Identifier predecessorAnimationId = AnimationService.getActiveAnimationId(world, instanceId); + if (predecessorAnimationId == null) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.animation_changed"); + return true; + } else if (defeated) { + return beginDefeatedJoin(world, requester, clickedEntity, instanceId, actors, predecessorAnimationId); + } else if (resolveConsentPlan(world, requester, actors, false) == null) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.not_accepting"); + return true; + } else { + List candidates = enumerateCandidates(world, actors, clickedEntity, predecessorAnimationId); + if (candidates.isEmpty()) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.no_match"); + return true; + } else { + UUID requestId = UUID.randomUUID(); + NonPlayerInteractionSystem.SelectionSession selection = new NonPlayerInteractionSystem.SelectionSession( + requestId, + NonPlayerInteractionSystem.RequestKind.JOIN, + world.getRegistryKey(), + requester.getUuid(), + clickedEntity.getUuid(), + instanceId, + world.getTime() + 300L, + actors.stream().map(Entity::getUuid).toList(), + List.copyOf(candidates) + ); + SELECTIONS.put(requestId, selection); + SESSION_BY_PLAYER.put(requester.getUuid(), requestId); + ServerPlayNetworking.send( + requester, + new PlayerAnimationSelectionS2CPayload( + requestId, clickedEntity.getUuid(), clickedEntity.getName().getString(), candidatesJson(selection, requester.getUuid()), true + ) + ); + return true; + } + } + } + } + } + } + + private static boolean beginDefeatedJoin( + ServerWorld world, ServerPlayerEntity requester, Entity clickedEntity, UUID instanceId, List actors, Identifier predecessorAnimationId + ) { + AnimationService.MatchedAnimation matched = NonAnimationMatcher.findAutomaticAttack( + world, actors, Set.of(), clickedEntity, true, requester, Set.of(), null, predecessorAnimationId + ); + if (matched == null) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.no_match"); + return true; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(matched.animationId()); + if (definition != null && isPlayerInteractionDefinition(definition)) { + NonPlayerInteractionSystem.ConsentPlan consentPlan = resolveConsentPlan(world, requester, actors, true); + if (consentPlan == null) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.not_accepting"); + return true; + } else { + NonPlayerInteractionSystem.PendingRequest request = new NonPlayerInteractionSystem.PendingRequest( + UUID.randomUUID(), + NonPlayerInteractionSystem.RequestKind.JOIN, + world.getRegistryKey(), + requester.getUuid(), + clickedEntity.getUuid(), + instanceId, + actors.stream().map(Entity::getUuid).toList(), + matched.animationId(), + definition.displayName(), + List.copyOf(matched.actorKeys()), + List.copyOf(matched.stages()), + definition.contentTags() == null ? List.of() : List.copyOf(definition.contentTags()), + world.getTime() + 300L, + consentPlan.consentPlayers(), + consentPlan.acceptedPlayers() + ); + executeRequest(world, request); + return true; + } + } else { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.no_match"); + return true; + } + } + + private static void handleAnimationChoice(ServerPlayerEntity requester, PlayerAnimationChoiceC2SPayload payload) { + NonPlayerInteractionSystem.SelectionSession selection = SELECTIONS.get(payload.requestId()); + if (selection != null && selection.requesterUuid().equals(requester.getUuid())) { + if (!(requester.getEntityWorld() instanceof ServerWorld world && world.getRegistryKey().equals(selection.worldKey()))) { + failSelection(selection, "failed", "gui.needsofnature.player_request.invalid"); + } else if (payload.choiceIndex() >= 0 && payload.choiceIndex() < selection.candidates().size()) { + Entity target = resolveEntity(world, selection.targetUuid()); + if (!validInteractionPair(requester, target)) { + failSelection(selection, "failed", "gui.needsofnature.player_request.out_of_range"); + } else if (selection.kind() == NonPlayerInteractionSystem.RequestKind.DIRECT && isBusyForDirectRequest(world, requester, target)) { + failSelection(selection, "failed", "gui.needsofnature.player_request.player_busy"); + } else if (selection.kind() == NonPlayerInteractionSystem.RequestKind.JOIN && !isSelectionSourceJoinable(world, selection)) { + failSelection(selection, "failed", "gui.needsofnature.player_request.animation_changed"); + } else { + List actors = resolveActors(world, selection.actorUuids()); + if (actors.size() != selection.actorUuids().size()) { + failSelection(selection, "failed", "gui.needsofnature.player_request.invalid"); + } else { + NonPlayerInteractionSystem.Candidate candidate = selection.candidates().get(payload.choiceIndex()); + if (!candidate.selectable()) { + failSelection(selection, "failed", "gui.needsofnature.player_request.block_requirement_unavailable"); + } else { + NonPlayerInteractionSystem.ConsentPlan consentPlan = resolveConsentPlan(world, requester, actors, false); + if (consentPlan == null) { + failSelection(selection, "failed", "gui.needsofnature.player_request.not_accepting"); + } else { + removeSelection(selection); + NonPlayerInteractionSystem.PendingRequest request = new NonPlayerInteractionSystem.PendingRequest( + selection.id(), + selection.kind(), + selection.worldKey(), + requester.getUuid(), + selection.targetUuid(), + selection.sourceInstanceUuid(), + selection.actorUuids(), + candidate.animationId(), + candidate.displayName(), + candidate.actorKeys(), + candidate.stages(), + candidate.contentTags(), + world.getTime() + 300L, + consentPlan.consentPlayers(), + consentPlan.acceptedPlayers() + ); + if (consentPlan.consentPlayers().isEmpty()) { + executeRequest(world, request); + } else if (!reserveRequest(request)) { + sendStandaloneFailure(requester, "gui.needsofnature.player_request.recipient_busy"); + } else { + promptConsent(world, request); + } + } + } + } + } + } else { + failSelection(selection, "failed", "gui.needsofnature.player_request.invalid"); + } + } + } + + private static void handleConsent(ServerPlayerEntity player, PlayerAnimationConsentC2SPayload payload) { + NonPlayerInteractionSystem.PendingRequest request = REQUESTS.get(payload.requestId()); + if (request != null && request.consentPlayers().contains(player.getUuid())) { + if (!payload.accepted()) { + finish(request, "failed", "gui.needsofnature.player_request.rejected"); + } else { + NonPlayerInteractionSystem.PendingRequest accepted = request.withAccepted(player.getUuid()); + REQUESTS.put(request.id(), accepted); + if (accepted.acceptedPlayers().containsAll(accepted.consentPlayers())) { + if (player.getEntityWorld() instanceof ServerWorld world && world.getRegistryKey().equals(accepted.worldKey())) { + executeRequest(world, accepted); + } else { + finish(accepted, "failed", "gui.needsofnature.player_request.invalid"); + } + } + } + } + } + + private static void handleCancel(ServerPlayerEntity player, UUID requestId) { + NonPlayerInteractionSystem.SelectionSession selection = SELECTIONS.get(requestId); + if (selection != null && selection.requesterUuid().equals(player.getUuid())) { + failSelection(selection, "cancelled", "gui.needsofnature.player_request.cancelled"); + } else { + NonPlayerInteractionSystem.PendingRequest request = REQUESTS.get(requestId); + if (request != null && (request.requesterUuid().equals(player.getUuid()) || request.consentPlayers().contains(player.getUuid()))) { + finish(request, "cancelled", "gui.needsofnature.player_request.cancelled"); + } + } + } + + private static void executeRequest(ServerWorld world, NonPlayerInteractionSystem.PendingRequest request) { + if (world != null && request != null) { + ServerPlayerEntity requester = getPlayer(world, request.requesterUuid()); + Entity interactionTarget = resolveEntity(world, request.interactionTargetUuid()); + if (!validInteractionPair(requester, interactionTarget)) { + finish(request, "failed", "gui.needsofnature.player_request.out_of_range"); + } else { + List actors = resolveActors(world, request.actorUuids()); + if (actors.size() != request.actorUuids().size()) { + finish(request, "failed", "gui.needsofnature.player_request.invalid"); + } else { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(request.animationId()); + if (definition != null && isPlayerInteractionDefinition(definition)) { + NonGatherSystem.cancelForActors(world, request.actorUuids()); + boolean started; + if (request.kind() == NonPlayerInteractionSystem.RequestKind.DIRECT) { + for (UUID actorUuid : request.actorUuids()) { + if (AnimationService.isActorPendingOrActive(world, actorUuid)) { + finish(request, "failed", "gui.needsofnature.player_request.player_busy"); + return; + } + } + + if (!AnimationService.isAnimationStartEligible(world, definition, actors, request.actorKeys(), interactionTarget)) { + finish(request, "failed", "gui.needsofnature.player_request.animation_changed"); + return; + } + + if (NeedsOfNature.isProtectedByAccessory(world, definition, actors) + && NeedsOfNature.consumeProtectorForAnimation(world, definition, actors)) { + finish(request, "failed", "gui.needsofnature.player_request.protected"); + return; + } + + UUID instanceId = NeedsOfNature.startAnimationNowWithContext( + world, + request.animationId(), + actors, + request.actorKeys(), + request.stages(), + NonAnimationDamageBehavior.STOP_ON_DAMAGE, + false, + interactionTarget, + NeedsOfNature.buildModeContext(false), + requester, + false, + true + ); + started = instanceId != null; + } else { + if (request.sourceInstanceUuid() == null || AnimationService.getCurrentStage(world, request.sourceInstanceUuid()) == null) { + finish(request, "failed", "gui.needsofnature.player_request.animation_changed"); + return; + } + + if (NeedsOfNature.isProtectedByAccessory(world, definition, actors) + && NeedsOfNature.consumeProtectorForAnimation(world, definition, actors)) { + finish(request, "failed", "gui.needsofnature.player_request.protected"); + return; + } + + started = NeedsOfNature.joinAnimationNow( + world, request.sourceInstanceUuid(), List.of(requester), requester, request.animationId(), interactionTarget, request.actorKeys() + ); + } + + finish( + request, + started ? "started" : "failed", + started ? "gui.needsofnature.player_request.started" : "gui.needsofnature.player_request.animation_changed" + ); + } else { + finish(request, "failed", "gui.needsofnature.player_request.animation_changed"); + } + } + } + } + } + + private static List enumerateCandidates( + ServerWorld world, List actors, Entity positionAnchor, @Nullable Identifier joinPredecessorAnimationId + ) { + List result = new ArrayList<>(); + + for (AnimationService.EligibleAnimation candidate : NonAnimationMatcher.findEligibleAutomatic( + world, actors, Set.of(), positionAnchor, false, null, Set.of(), joinPredecessorAnimationId + )) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(candidate.animationId()); + if (definition != null && isPlayerInteractionDefinition(definition)) { + boolean selectable = AnimationService.isAnimationStartEligible(world, definition, actors, candidate.actorKeys(), positionAnchor); + AnimationDefinitions.BlockRequirements blockRequirements = definition.blockRequirements(); + if (selectable || blockRequirements != null) { + result.add( + new NonPlayerInteractionSystem.Candidate( + candidate.animationId(), + candidate.displayName(), + candidate.actorKeys(), + candidate.stages(), + candidate.contentTags(), + selectable, + summarizeBlockRequirement(blockRequirements), + candidate.packName(), + candidate.packAuthor() + ) + ); + } + } + } + + return List.copyOf(result); + } + + @Nullable + private static NonPlayerInteractionSystem.ConsentPlan resolveConsentPlan( + ServerWorld world, ServerPlayerEntity requester, List actors, boolean attack + ) { + Set consentPlayers = new LinkedHashSet<>(); + Set acceptedPlayers = new LinkedHashSet<>(); + + for (Entity actor : actors) { + if (actor instanceof ServerPlayerEntity existingPlayer && !existingPlayer.getUuid().equals(requester.getUuid())) { + NonPlayerInteractionProfiles.Mode mode = resolveMode(world, existingPlayer, requester); + if (attack) { + if (!mode.attacksAllowed()) { + return null; + } + } else { + if (mode.requestPolicy() == NonPlayerInteractionProfiles.RequestPolicy.DENY) { + return null; + } + + if (mode.requestPolicy() == NonPlayerInteractionProfiles.RequestPolicy.AUTO_ACCEPT) { + acceptedPlayers.add(existingPlayer.getUuid()); + } else { + consentPlayers.add(existingPlayer.getUuid()); + } + } + } + } + + return new NonPlayerInteractionSystem.ConsentPlan(Set.copyOf(consentPlayers), Set.copyOf(acceptedPlayers)); + } + + private static boolean isSelectionSourceJoinable(ServerWorld world, NonPlayerInteractionSystem.SelectionSession selection) { + if (world == null || selection == null || selection.sourceInstanceUuid() == null) { + return false; + } + + if (!NeedsOfNature.isVoluntaryJoinableInstance(world, selection.sourceInstanceUuid())) { + return false; + } + + UUID targetInstance = NeedsOfNature.findActiveInstanceContainingActor(world, selection.targetUuid()); + return selection.sourceInstanceUuid().equals(targetInstance); + } + + static boolean isPlayerInteractionDefinition(AnimationDefinitions.Definition definition) { + if (definition == null) { + return false; + } + + if (definition.animationTags() == null) { + return true; + } + + for (String rawTag : definition.animationTags()) { + String tag = rawTag == null ? "" : rawTag.trim().toLowerCase(Locale.ROOT); + if (tag.equals("manualpeak") || tag.equals("fillbottle") || tag.equals("birth") || tag.startsWith("birth_") || tag.startsWith("defeat.")) { + return false; + } + } + + return true; + } + + private static boolean reserveRequest(NonPlayerInteractionSystem.PendingRequest request) { + Set participants = new LinkedHashSet<>(request.consentPlayers()); + participants.add(request.requesterUuid()); + + for (UUID playerUuid : participants) { + if (SESSION_BY_PLAYER.containsKey(playerUuid)) { + return false; + } + } + + REQUESTS.put(request.id(), request); + + for (UUID playerUuid : participants) { + SESSION_BY_PLAYER.put(playerUuid, request.id()); + } + + return true; + } + + private static void promptConsent(ServerWorld world, NonPlayerInteractionSystem.PendingRequest request) { + ServerPlayerEntity requester = getPlayer(world, request.requesterUuid()); + if (requester == null) { + finish(request, "failed", "gui.needsofnature.player_request.invalid"); + } else { + ServerPlayNetworking.send(requester, new PlayerAnimationRequestStatusS2CPayload(request.id(), "waiting", "gui.needsofnature.player_request.waiting")); + String tagsJson = stringListJson(request.contentTags()); + + for (UUID recipientUuid : request.consentPlayers()) { + ServerPlayerEntity recipient = getPlayer(world, recipientUuid); + if (recipient == null) { + finish(request, "failed", "gui.needsofnature.player_request.disconnected"); + return; + } + + ServerPlayNetworking.send( + recipient, + new PlayerAnimationConsentS2CPayload( + request.id(), + requester.getUuid(), + requester.getGameProfile().name(), + request.animationId().toString(), + request.displayName() == null ? "" : request.displayName(), + tagsJson, + request.kind() == NonPlayerInteractionSystem.RequestKind.JOIN + ) + ); + } + } + } + + private static void handleProfileAction(ServerPlayerEntity player, PlayerProfileActionC2SPayload payload) { + if (player != null && player.getEntityWorld() instanceof ServerWorld world) { + NonPlayerInteractionProfiles profiles = NonPlayerInteractionProfiles.get(world); + profiles.recordKnownPlayer(player); + String action = payload.action() == null ? "" : payload.action().trim().toLowerCase(Locale.ROOT); + if (action.equals("request")) { + sendProfileSnapshot(player, ""); + } else { + NonPlayerInteractionProfiles.Mode mode = NonPlayerInteractionProfiles.Mode.fromId( + payload.mode(), NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS + ); + if (action.equals("default")) { + profiles.setDefaultMode(player.getUuid(), mode, allowedMask()); + sendProfileSnapshot(player, ""); + } else { + NonPlayerInteractionProfiles.KnownPlayer target = findKnownPlayer(profiles, payload.target()); + if (target == null) { + sendProfileSnapshot(player, "gui.needsofnature.player_profiles.unknown_player"); + } else { + if (action.equals("set")) { + profiles.setOverride(player.getUuid(), target.uuid(), target.name(), mode, allowedMask()); + } else if (action.equals("remove")) { + profiles.removeOverride(player.getUuid(), target.uuid()); + } + + sendProfileSnapshot(player, ""); + } + } + } + } + } + + @Nullable + private static NonPlayerInteractionProfiles.KnownPlayer findKnownPlayer(NonPlayerInteractionProfiles profiles, String input) { + if (profiles != null && input != null && !input.isBlank()) { + try { + NonPlayerInteractionProfiles.KnownPlayer byUuid = profiles.findKnownPlayer(UUID.fromString(input.trim())); + if (byUuid != null) { + return byUuid; + } + } catch (IllegalArgumentException var3) { + } + + return profiles.findKnownPlayerByName(input); + } else { + return null; + } + } + + public static void sendProfileSnapshot(ServerPlayerEntity player, String errorKey) { + if (player != null && player.getEntityWorld() instanceof ServerWorld world) { + NonPlayerInteractionProfiles.Snapshot var10 = NonPlayerInteractionProfiles.get(world).snapshot(player.getUuid(), allowedMask()); + JsonObject root = new JsonObject(); + root.addProperty("default_mode", var10.defaultMode().id()); + root.addProperty("allowed_mask", allowedMask()); + JsonArray overrides = new JsonArray(); + + for (NonPlayerInteractionProfiles.OverrideView entry : var10.overrides()) { + JsonObject object = new JsonObject(); + object.addProperty("uuid", entry.uuid().toString()); + object.addProperty("name", entry.name()); + object.addProperty("mode", entry.mode().id()); + overrides.add(object); + } + + root.add("overrides", overrides); + JsonArray knownPlayers = new JsonArray(); + + for (NonPlayerInteractionProfiles.KnownPlayer entry : var10.knownPlayers()) { + JsonObject object = new JsonObject(); + object.addProperty("uuid", entry.uuid().toString()); + object.addProperty("name", entry.name()); + knownPlayers.add(object); + } + + root.add("known_players", knownPlayers); + ServerPlayNetworking.send(player, new PlayerProfileSnapshotS2CPayload(root.toString(), errorKey == null ? "" : errorKey)); + } + } + + private static String candidatesJson(NonPlayerInteractionSystem.SelectionSession session, UUID requesterUuid) { + JsonArray array = new JsonArray(); + int requesterIndex = session.actorUuids().indexOf(requesterUuid); + + for (int index = 0; index < session.candidates().size(); index++) { + NonPlayerInteractionSystem.Candidate candidate = session.candidates().get(index); + JsonObject object = new JsonObject(); + object.addProperty("index", index); + object.addProperty("animation_id", candidate.animationId().toString()); + object.addProperty("display_name", candidate.displayName() == null ? "" : candidate.displayName()); + object.addProperty("requester_role", roleAt(candidate.actorKeys(), requesterIndex)); + object.addProperty("requester_activity", activityAt(candidate, requesterIndex)); + object.addProperty("selectable", candidate.selectable()); + object.addProperty("block_requirement", candidate.blockRequirementSummary()); + object.add("actor_keys", toJsonArray(candidate.actorKeys())); + object.add("content_tags", toJsonArray(candidate.contentTags())); + object.addProperty("pack_name", candidate.packName() == null ? "" : candidate.packName()); + object.addProperty("pack_author", candidate.packAuthor() == null ? "" : candidate.packAuthor()); + array.add(object); + } + + return array.toString(); + } + + private static String activityAt(NonPlayerInteractionSystem.Candidate candidate, int actorIndex) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(candidate.animationId()); + if (definition == null) { + return "active"; + } else { + String actorKey = roleAt(candidate.actorKeys(), actorIndex); + int constraintIndex = AnimationDefinitions.resolveActorKeys(definition).indexOf(actorKey); + if (constraintIndex >= 0 && constraintIndex < definition.actors().size()) { + AnimationDefinitions.ActorActivity activity = definition.actors().get(constraintIndex).activity(); + return activity == AnimationDefinitions.ActorActivity.PASSIVE ? "passive" : "active"; + } else { + return "active"; + } + } + } + + private static String summarizeBlockRequirement(@Nullable AnimationDefinitions.BlockRequirements requirements) { + if (requirements == null) { + return ""; + } + + List parts = new ArrayList<>(); + if (requirements.isWall()) { + parts.add("墙"); + AnimationDefinitions.WallHeight height = requirements.height(); + if (height != null) { + parts.add(height.max() == null ? "高度 " + height.min() + "+" : "高度 " + height.min() + "-" + height.max()); + } + + AnimationDefinitions.WallWidth width = requirements.width(); + if (width != null && (width.min() > 1 || width.max() != null)) { + parts.add(width.max() == null ? "宽度 " + width.min() + "+" : "宽度 " + width.min() + "-" + width.max()); + } + + if (requirements.facing() != null) { + parts.add("朝向 " + localizeFacing(requirements.facing().id())); + } + } else if (requirements.isCenterSupport()) { + parts.add("中心支撑"); + if (requirements.support() != null) { + parts.add(requirements.support().id().replace('_', ' ')); + } + + AnimationDefinitions.SurfaceFootprint footprint = requirements.surfaceFootprint(); + if (footprint != null) { + parts.add("占地 " + footprint.width() + "x" + footprint.depth()); + if (footprint.margin() > 0) { + parts.add("边距 " + footprint.margin()); + } + } + } else { + parts.add(localizeBlockType(requirements.type() == null ? "blocks" : requirements.type())); + } + + AnimationDefinitions.Clearance clearance = requirements.clearance(); + if (clearance != null) { + parts.add("净空 " + clearance.width() + "x" + clearance.height() + "x" + clearance.depth()); + } + + AnimationDefinitions.BlockPredicate blocks = requirements.blocks(); + if (blocks != null && !blocks.isEmpty()) { + parts.add("方块 " + summarizeBlockPredicate(blocks)); + } + + return String.join(" | ", parts); + } + + private static String localizeBlockType(String type) { + return switch (type) { + case "wall" -> "墙"; + case "center support" -> "中心支撑"; + case "surface" -> "表面"; + default -> type.replace('_', ' '); + }; + } + + private static String localizeFacing(String facing) { + return switch (facing) { + case "north" -> "北"; + case "south" -> "南"; + case "east" -> "东"; + case "west" -> "西"; + case "up" -> "上"; + case "down" -> "下"; + default -> facing; + }; + } + + private static String summarizeBlockPredicate(AnimationDefinitions.BlockPredicate blocks) { + List values = new ArrayList<>(); + blocks.blockIds().stream().map(Identifier::toString).sorted().forEach(values::add); + blocks.tagIds().stream().map(id -> "#" + id).sorted().forEach(values::add); + return values.size() <= 3 ? String.join(", ", values) : String.join(", ", values.subList(0, 3)) + ", +" + (values.size() - 3); + } + + private static String roleAt(List actorKeys, int index) { + return actorKeys != null && index >= 0 && index < actorKeys.size() ? actorKeys.get(index) : "player"; + } + + private static JsonArray toJsonArray(List values) { + JsonArray result = new JsonArray(); + if (values != null) { + for (String value : values) { + result.add(value == null ? "" : value); + } + } + + return result; + } + + private static String stringListJson(List values) { + return toJsonArray(values).toString(); + } + + private static void failSelection(NonPlayerInteractionSystem.SelectionSession selection, String state, String messageKey) { + ServerPlayerEntity requester = findPlayer(selection.requesterUuid()); + removeSelection(selection); + if (requester != null) { + ServerPlayNetworking.send(requester, new PlayerAnimationRequestStatusS2CPayload(selection.id(), state, messageKey)); + } + } + + private static void removeSelection(NonPlayerInteractionSystem.SelectionSession selection) { + if (selection != null) { + SELECTIONS.remove(selection.id(), selection); + SESSION_BY_PLAYER.remove(selection.requesterUuid(), selection.id()); + } + } + + private static void finish(NonPlayerInteractionSystem.PendingRequest request, String state, String messageKey) { + if (request != null) { + REQUESTS.remove(request.id()); + Set participants = new LinkedHashSet<>(request.consentPlayers()); + participants.add(request.requesterUuid()); + + for (UUID playerUuid : participants) { + SESSION_BY_PLAYER.remove(playerUuid, request.id()); + ServerPlayerEntity player = findPlayer(playerUuid); + if (player != null) { + ServerPlayNetworking.send(player, new PlayerAnimationRequestStatusS2CPayload(request.id(), state, messageKey)); + } + } + } + } + + private static void cancelForPlayer(UUID playerUuid, String messageKey) { + if (playerUuid != null) { + UUID sessionId = SESSION_BY_PLAYER.get(playerUuid); + if (sessionId != null) { + NonPlayerInteractionSystem.SelectionSession selection = SELECTIONS.get(sessionId); + if (selection != null) { + failSelection(selection, "cancelled", messageKey); + } else { + NonPlayerInteractionSystem.PendingRequest request = REQUESTS.get(sessionId); + if (request != null) { + finish(request, "cancelled", messageKey); + } + } + } + } + } + + private static void sendStandaloneFailure(ServerPlayerEntity player, String messageKey) { + if (player != null) { + UUID requestId = UUID.randomUUID(); + ServerPlayNetworking.send(player, new PlayerAnimationRequestStatusS2CPayload(requestId, "failed", messageKey)); + } + } + + private static NonPlayerInteractionProfiles.Mode resolveMode(ServerWorld world, ServerPlayerEntity owner, ServerPlayerEntity other) { + return NonPlayerInteractionProfiles.get(world).resolveMode(owner.getUuid(), other.getUuid(), allowedMask()); + } + + private static int allowedMask() { + return NeedsOfNature.getConfig().getAllowedPlayerInteractionProfilesMask(); + } + + private static boolean hasRequestEnergy(ServerPlayerEntity player) { + return player instanceof EnergyHolder holder && holder.getEnergy() >= NeedsOfNature.getConfig().getPlayerRequestMinEnergy(); + } + + private static boolean isUsablePlayer(@Nullable ServerPlayerEntity player) { + return player != null && !player.isSpectator() && player.isAlive() && !player.isRemoved(); + } + + private static boolean validInteractionPair(@Nullable ServerPlayerEntity requester, @Nullable Entity target) { + if (isUsablePlayer(requester) && target != null && !target.isRemoved()) { + return requester.getEntityWorld() != target.getEntityWorld() ? false : requester.canInteractWithEntity(target, 0.0); + } else { + return false; + } + } + + private static boolean isBusyForDirectRequest(ServerWorld world, @Nullable ServerPlayerEntity requester, @Nullable Entity target) { + return requester != null && target instanceof ServerPlayerEntity targetPlayer + ? AnimationService.isActorPendingOrActive(world, requester.getUuid()) || AnimationService.isActorPendingOrActive(world, targetPlayer.getUuid()) + : true; + } + + private static List sortedActors(Entity first, Entity second) { + List result = new ArrayList<>(List.of(first, second)); + result.sort(Comparator.comparingInt(Entity::getId)); + return List.copyOf(result); + } + + private static List entitiesForJoin(ServerWorld world, UUID instanceId, ServerPlayerEntity requester) { + List actors = resolveActors(world, NeedsOfNature.getInstanceActors(world, instanceId)); + if (actors.isEmpty()) { + return List.of(); + } + + if (actors.stream().noneMatch(actor -> actor.getUuid().equals(requester.getUuid()))) { + actors.add(requester); + } + + actors.sort(Comparator.comparingInt(Entity::getId)); + return List.copyOf(actors); + } + + private static List resolveActors(ServerWorld world, List actorUuids) { + List result = new ArrayList<>(); + if (world != null && actorUuids != null) { + for (UUID actorUuid : actorUuids) { + Entity actor = resolveEntity(world, actorUuid); + if (actor != null && !actor.isRemoved()) { + result.add(actor); + } + } + + result.sort(Comparator.comparingInt(Entity::getId)); + return result; + } else { + return result; + } + } + + @Nullable + private static Entity resolveEntity(ServerWorld world, UUID uuid) { + if (world != null && uuid != null) { + ServerPlayerEntity player = getPlayer(world, uuid); + return (Entity)(player != null ? player : world.getEntity(uuid)); + } else { + return null; + } + } + + @Nullable + private static ServerPlayerEntity getPlayer(ServerWorld world, UUID uuid) { + if (world != null && uuid != null && world.getServer() != null) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(uuid); + return player != null && player.getEntityWorld() == world ? player : null; + } else { + return null; + } + } + + @Nullable + private static ServerPlayerEntity findPlayer(UUID uuid) { + return uuid != null && activeServer != null ? activeServer.getPlayerManager().getPlayer(uuid) : null; + } + + private record Candidate( + Identifier animationId, + @Nullable String displayName, + List actorKeys, + List stages, + List contentTags, + boolean selectable, + String blockRequirementSummary, + @Nullable String packName, + @Nullable String packAuthor + ) { + } + + private record ConsentPlan(Set consentPlayers, Set acceptedPlayers) { + } + + private record PendingRequest( + UUID id, + NonPlayerInteractionSystem.RequestKind kind, + RegistryKey worldKey, + UUID requesterUuid, + UUID interactionTargetUuid, + @Nullable UUID sourceInstanceUuid, + List actorUuids, + Identifier animationId, + @Nullable String displayName, + List actorKeys, + List stages, + List contentTags, + long expiresTick, + Set consentPlayers, + Set acceptedPlayers + ) { + private NonPlayerInteractionSystem.PendingRequest withAccepted(UUID playerUuid) { + Set accepted = new LinkedHashSet<>(this.acceptedPlayers); + accepted.add(playerUuid); + return new NonPlayerInteractionSystem.PendingRequest( + this.id, + this.kind, + this.worldKey, + this.requesterUuid, + this.interactionTargetUuid, + this.sourceInstanceUuid, + this.actorUuids, + this.animationId, + this.displayName, + this.actorKeys, + this.stages, + this.contentTags, + this.expiresTick, + this.consentPlayers, + Set.copyOf(accepted) + ); + } + } + + private enum RequestKind { + DIRECT, + JOIN; + } + + private record SelectionSession( + UUID id, + NonPlayerInteractionSystem.RequestKind kind, + RegistryKey worldKey, + UUID requesterUuid, + UUID targetUuid, + @Nullable UUID sourceInstanceUuid, + long expiresTick, + List actorUuids, + List candidates + ) { + } +} diff --git a/src/main/java/com/nonid/NonPregnancyConceptionSystem.java b/src/main/java/com/nonid/NonPregnancyConceptionSystem.java new file mode 100644 index 0000000..0609e43 --- /dev/null +++ b/src/main/java/com/nonid/NonPregnancyConceptionSystem.java @@ -0,0 +1,226 @@ +package com.nonid; + +import com.nonid.data.NonEntityProfiles; +import com.nonid.network.PregnancyCycleStateS2CPayload; +import java.util.List; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; + +final class NonPregnancyConceptionSystem { + private static final Identifier PLAYER_ENTITY_TYPE_ID = Identifier.of("minecraft", "player"); + private static final long TICKS_PER_DAY = 24000L; + private static final long CYCLE_TICKS = 720000L; + private static final long CHECK_INTERVAL_TICKS = 20L; + private static final double CHECKS_PER_MINUTE = 60.0; + private static final int SAFE_PHASE_FIRST_END_DAY = 5; + private static final int SAFE_PHASE_SECOND_START_DAY = 26; + + private NonPregnancyConceptionSystem() { + } + + static void tick(ServerWorld world) { + if (world != null && !world.getPlayers().isEmpty() && world.getTime() % 20L == 0L) { + for (ServerPlayerEntity player : world.getPlayers()) { + if (isCycleEnabledFor(player)) { + updateCycle(player, world, true); + tryConception(world, player); + } + } + } + } + + static void syncState(ServerPlayerEntity player) { + if (player != null && player.getEntityWorld() instanceof ServerWorld world) { + if (!isCycleEnabledFor(player)) { + ServerPlayNetworking.send(player, new PregnancyCycleStateS2CPayload(false, 0, PregnancyCyclePhase.NORMAL)); + } else { + updateCycle(player, world, false); + int day = getCycleDay(player); + ServerPlayNetworking.send(player, new PregnancyCycleStateS2CPayload(true, day, resolvePhase(day))); + } + } + } + + static String describeCycle(ServerPlayerEntity player) { + if (player != null && isCycleEnabledFor(player)) { + if (player.getEntityWorld() instanceof ServerWorld world) { + updateCycle(player, world, false); + } + + int day = getCycleDay(player); + return "pregnancy cycle: day " + day + "/30 (" + resolvePhase(day).displayName() + ")"; + } else { + return "pregnancy cycle: disabled"; + } + } + + static void copyState(ServerPlayerEntity oldPlayer, ServerPlayerEntity newPlayer) { + if (oldPlayer instanceof PregnancyCycleHolder oldHolder && newPlayer instanceof PregnancyCycleHolder newHolder) { + if (!oldHolder.isPregnancyCycleInitialized()) { + newHolder.clearPregnancyCycleState(); + } else { + newHolder.setPregnancyCycleState( + oldHolder.getPregnancyCycleProgressTicks(), oldHolder.getPregnancyCycleLastGameTick(), oldHolder.getPregnancyCycleLastTimeOfDay() + ); + } + } + } + + static void recordRecentDonor(LiquidHolder holder, Identifier entityTypeId, NeedsOfNature.PregnancyVariantData variantData) { + if (holder != null && holder.getLiquidStored() > 0 && entityTypeId != null && !PLAYER_ENTITY_TYPE_ID.equals(entityTypeId)) { + holder.recordRecentLiquidDonor(entityTypeId, variantData); + } + } + + static boolean setCycleDay(ServerPlayerEntity player, int day) { + if (player instanceof PregnancyCycleHolder holder && player.getEntityWorld() instanceof ServerWorld world) { + ServerWorld clockWorld = world.getServer() != null && world.getServer().getOverworld() != null ? world.getServer().getOverworld() : world; + int clampedDay = Math.max(1, Math.min(30, day)); + long progressTicks = (clampedDay - 1) * 24000L; + holder.setPregnancyCycleState(progressTicks, Math.max(0L, clockWorld.getTime()), Math.max(0L, clockWorld.getTimeOfDay())); + syncState(player); + return true; + } else { + return false; + } + } + + private static void tryConception(ServerWorld world, ServerPlayerEntity player) { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null && config.isPregnancyEnabled() && config.isLiquidBasedPregnancyEnabled()) { + if (config.isLiquidTankEnabled() && player.isAlive() && !player.isRemoved() && !player.isSpectator()) { + if (NeedsOfNature.getPregnantEntityTypeId(player) == null && !NonTrinketsIntegration.blocksPregnancy(player)) { + if (player instanceof LiquidHolder holder) { + if (holder.getLiquidStored() > 0 && holder.getLiquidCapacity() > 0) { + Identifier sourceType; + NeedsOfNature.PregnancyVariantData sourceVariantData; + if (holder.getLiquidComposition() == LiquidHolder.LiquidComposition.ENTITY) { + sourceType = holder.getLiquidEntityTypeId(); + sourceVariantData = holder.getLiquidDonorVariantData(); + } else { + if (holder.getLiquidComposition() != LiquidHolder.LiquidComposition.MIXED) { + return; + } + + List donors = holder.getRecentLiquidDonors(); + if (donors.isEmpty()) { + return; + } + + LiquidDonorHistory.Entry selected = donors.get(world.getRandom().nextInt(donors.size())); + sourceType = selected.entityTypeId(); + sourceVariantData = selected.variantData(); + } + + if (sourceType != null && !PLAYER_ENTITY_TYPE_ID.equals(sourceType)) { + int profileChance = NonEntityProfiles.resolve(sourceType).pregnancyChancePercent(); + if (profileChance > 0) { + NonAccessoryEffects accessoryEffects = NonTrinketsIntegration.getActiveTankAccessoryEffects(player); + double fillRatio = Math.min(1.0, (double)holder.getLiquidStored() / holder.getLiquidCapacity()); + int cycleDay = getCycleDay(player); + PregnancyCyclePhase phase = resolvePhase(cycleDay); + + double cycleMultiplier = switch (phase) { + case SAFE -> config.getPregnancySafeDayChancePercent() / 100.0; + case NORMAL -> 1.0; + case RISKY -> config.getPregnancyRiskyChancePercent() / 100.0; + case OVULATION -> config.getPregnancyOvulationChancePercent() / 100.0; + }; + double accessoryMultiplier = Math.max(0.0, accessoryEffects.pregnancyChanceMultiplier()); + double naturalMinuteChance = profileChance / 100.0 * fillRatio * cycleMultiplier * accessoryMultiplier; + int fertilityFloor = NeedsOfNature.getFertilityIncreaserChanceFloor(player); + double potionMinuteChance = 0.0; + if (fertilityFloor > 0) { + potionMinuteChance = fertilityFloor / 100.0 * fillRatio * cycleMultiplier * accessoryMultiplier; + if (phase == PregnancyCyclePhase.RISKY || phase == PregnancyCyclePhase.OVULATION) { + potionMinuteChance = Math.min(potionMinuteChance, fertilityFloor / 100.0 * fillRatio); + } + } + + double minuteChance = Math.max(naturalMinuteChance, potionMinuteChance); + minuteChance = Math.max(0.0, Math.min(1.0, minuteChance)); + if (!(minuteChance <= 0.0)) { + double checkChance = minuteChance >= 1.0 ? 1.0 : 1.0 - Math.pow(1.0 - minuteChance, 0.016666666666666666); + if (!(world.getRandom().nextDouble() >= checkChance)) { + long durationTicks = Math.max( + 1L, Math.round(config.getPregnancyDurationTicks() * Math.max(0.01, accessoryEffects.pregnancyDurationMultiplier())) + ); + NeedsOfNature.beginPregnancy(player, sourceType, NeedsOfNature.getGlobalTick(world) + durationTicks, sourceVariantData); + if (sourceType.equals(NeedsOfNature.getPregnantEntityTypeId(player))) { + NonStats.incrementPregnancyStarted(player); + NonAdvancementHooks.grantFirstPregnancy(player); + } + } + } + } + } + } + } + } + } + } + } + + private static boolean isCycleEnabledFor(ServerPlayerEntity player) { + NonConfig config = NeedsOfNature.getConfig(); + return player != null && config != null && config.isPregnancyEnabled() && config.isLiquidBasedPregnancyEnabled() && !NonGenderSystem.isOnlyMale(player); + } + + private static void updateCycle(ServerPlayerEntity player, ServerWorld world, boolean syncOnDayChange) { + if (player instanceof PregnancyCycleHolder holder) { + ServerWorld clockWorld = world.getServer() != null && world.getServer().getOverworld() != null ? world.getServer().getOverworld() : world; + long nowGameTick = Math.max(0L, clockWorld.getTime()); + long nowTimeOfDay = Math.max(0L, clockWorld.getTimeOfDay()); + if (!holder.isPregnancyCycleInitialized()) { + long initialProgress = Math.floorMod(world.getRandom().nextLong(), 720000L); + holder.setPregnancyCycleState(initialProgress, nowGameTick, nowTimeOfDay); + if (syncOnDayChange) { + syncState(player); + } + } else { + int oldDay = getCycleDay(holder.getPregnancyCycleProgressTicks()); + long gameDelta = nonNegativeDelta(nowGameTick, holder.getPregnancyCycleLastGameTick()); + long daytimeDelta = nonNegativeDelta(nowTimeOfDay, holder.getPregnancyCycleLastTimeOfDay()); + long elapsed = Math.max(gameDelta, daytimeDelta); + long progress = Math.floorMod(holder.getPregnancyCycleProgressTicks() + Math.floorMod(elapsed, 720000L), 720000L); + holder.setPregnancyCycleState(progress, nowGameTick, nowTimeOfDay); + if (syncOnDayChange && oldDay != getCycleDay(progress)) { + syncState(player); + } + } + } + } + + private static long nonNegativeDelta(long current, long previous) { + return current >= previous ? current - previous : 0L; + } + + private static int getCycleDay(ServerPlayerEntity player) { + return player instanceof PregnancyCycleHolder holder && holder.isPregnancyCycleInitialized() ? getCycleDay(holder.getPregnancyCycleProgressTicks()) : 1; + } + + private static int getCycleDay(long progressTicks) { + long normalized = Math.floorMod(progressTicks, 720000L); + return (int)(normalized / 24000L) + 1; + } + + private static PregnancyCyclePhase resolvePhase(int day) { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null) { + int riskyStartDay = config.getPregnancyRiskyStartDay(); + int ovulationEndDay = config.getPregnancyOvulationEndDay(); + int ovulationStartDay = Math.max(riskyStartDay, ovulationEndDay - 1); + if (day >= ovulationStartDay && day <= ovulationEndDay) { + return PregnancyCyclePhase.OVULATION; + } + + if (day >= riskyStartDay && day < ovulationStartDay) { + return PregnancyCyclePhase.RISKY; + } + } + + return day > 5 && day < 26 ? PregnancyCyclePhase.NORMAL : PregnancyCyclePhase.SAFE; + } +} diff --git a/src/main/java/com/nonid/NonSkinRepairBlocks.java b/src/main/java/com/nonid/NonSkinRepairBlocks.java new file mode 100644 index 0000000..7fb6e00 --- /dev/null +++ b/src/main/java/com/nonid/NonSkinRepairBlocks.java @@ -0,0 +1,33 @@ +package com.nonid; + +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; + +public final class NonSkinRepairBlocks { + private NonSkinRepairBlocks() { + } + + public static boolean isNearRepairBlock(World world, BlockPos center, NonConfig config) { + if (world != null && center != null) { + for (BlockPos pos : BlockPos.iterateOutwards(center, 1, 1, 1)) { + if (isRepairBlock(world.getBlockState(pos), config)) { + return true; + } + } + + return false; + } else { + return false; + } + } + + public static boolean isRepairBlock(BlockState state, NonConfig config) { + if (state == null) { + return false; + } else { + return state.isOf(Blocks.LOOM) ? true : config != null && config.isCraftingTableSkinRepairAllowed() && state.isOf(Blocks.CRAFTING_TABLE); + } + } +} diff --git a/src/main/java/com/nonid/NonStats.java b/src/main/java/com/nonid/NonStats.java new file mode 100644 index 0000000..f3257d2 --- /dev/null +++ b/src/main/java/com/nonid/NonStats.java @@ -0,0 +1,247 @@ +package com.nonid; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.stat.StatFormatter; +import net.minecraft.stat.Stats; +import net.minecraft.util.Identifier; + +public final class NonStats { + private static final String STAT_PATH_PEAKED_TOTAL = "peaked_animations_total"; + private static final String STAT_PATH_LIQUID_GAINED_TOTAL = "liquid_gained_ml_total"; + private static final String STAT_PATH_MULTI_ACTOR_TOTAL = "multi_actor_animations_total"; + private static final String STAT_PATH_WATER_TOTAL = "water_animations_total"; + private static final String STAT_PATH_ANIMATION_START_TOTAL = "animations_started_total"; + private static final String STAT_PATH_ATTACK_ANIMATION_START_TOTAL = "attack_animations_started_total"; + private static final String STAT_PATH_ATTACK_ESCAPE_SUCCESS_TOTAL = "attack_escapes_success_total"; + private static final String STAT_PATH_M_INJECTOR_PEAKS_ML_TOTAL = "m_injector_peaks_ml_total"; + private static final String STAT_PATH_MANUAL_PEAK_TOTAL = "manual_peaks_total"; + private static final String STAT_PATH_FILL_BOTTLE_TOTAL = "bottles_filled_total"; + private static final String STAT_PATH_PREGNANCY_STARTED_TOTAL = "pregnancies_started_total"; + private static final String STAT_PATH_PREGNANCY_COMPLETED_TOTAL = "pregnancies_completed_total"; + private static final String STAT_PATH_OFFSPRING_SPAWNED_TOTAL = "offspring_spawned_total"; + private static final Identifier STAT_PEAKED_TOTAL = NeedsOfNature.id("peaked_animations_total"); + private static final Identifier STAT_LIQUID_GAINED_TOTAL = NeedsOfNature.id("liquid_gained_ml_total"); + private static final Identifier STAT_MULTI_ACTOR_TOTAL = NeedsOfNature.id("multi_actor_animations_total"); + private static final Identifier STAT_WATER_TOTAL = NeedsOfNature.id("water_animations_total"); + private static final Identifier STAT_ANIMATION_START_TOTAL = NeedsOfNature.id("animations_started_total"); + private static final Identifier STAT_ATTACK_ANIMATION_START_TOTAL = NeedsOfNature.id("attack_animations_started_total"); + private static final Identifier STAT_ATTACK_ESCAPE_SUCCESS_TOTAL = NeedsOfNature.id("attack_escapes_success_total"); + private static final Identifier STAT_M_INJECTOR_PEAKS_ML_TOTAL = NeedsOfNature.id("m_injector_peaks_ml_total"); + private static final Identifier STAT_MANUAL_PEAK_TOTAL = NeedsOfNature.id("manual_peaks_total"); + private static final Identifier STAT_FILL_BOTTLE_TOTAL = NeedsOfNature.id("bottles_filled_total"); + private static final Identifier STAT_PREGNANCY_STARTED_TOTAL = NeedsOfNature.id("pregnancies_started_total"); + private static final Identifier STAT_PREGNANCY_COMPLETED_TOTAL = NeedsOfNature.id("pregnancies_completed_total"); + private static final Identifier STAT_OFFSPRING_SPAWNED_TOTAL = NeedsOfNature.id("offspring_spawned_total"); + private static final Map PEAKED_BY_ENTITY = new LinkedHashMap<>(); + private static final Map LIQUID_GAINED_BY_ENTITY = new LinkedHashMap<>(); + private static final Map OFFSPRING_SPAWNED_BY_ENTITY = new LinkedHashMap<>(); + + private NonStats() { + } + + public static void registerBaseStats() { + registerStat(STAT_PEAKED_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_LIQUID_GAINED_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_MULTI_ACTOR_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_WATER_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_ANIMATION_START_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_ATTACK_ANIMATION_START_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_ATTACK_ESCAPE_SUCCESS_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_M_INJECTOR_PEAKS_ML_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_MANUAL_PEAK_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_FILL_BOTTLE_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_PREGNANCY_STARTED_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_PREGNANCY_COMPLETED_TOTAL, StatFormatter.DEFAULT); + registerStat(STAT_OFFSPRING_SPAWNED_TOTAL, StatFormatter.DEFAULT); + } + + public static void registerKnownEntityStats() { + for (Identifier entityId : Registries.ENTITY_TYPE.getIds()) { + ensureEntityStats(entityId); + } + } + + public static void incrementAnimationStarted(ServerPlayerEntity player) { + increase(player, STAT_ANIMATION_START_TOTAL, 1); + } + + public static void incrementMultiActorAnimation(ServerPlayerEntity player) { + increase(player, STAT_MULTI_ACTOR_TOTAL, 1); + } + + public static void incrementWaterAnimation(ServerPlayerEntity player) { + increase(player, STAT_WATER_TOTAL, 1); + } + + public static void incrementAttackAnimationStarted(ServerPlayerEntity player) { + increase(player, STAT_ATTACK_ANIMATION_START_TOTAL, 1); + } + + public static void incrementAttackEscapeSuccess(ServerPlayerEntity player) { + increase(player, STAT_ATTACK_ESCAPE_SUCCESS_TOTAL, 1); + } + + public static void addMInjectorPeaksMl(ServerPlayerEntity player, int amount) { + increase(player, STAT_M_INJECTOR_PEAKS_ML_TOTAL, amount); + } + + public static void incrementManualPeak(ServerPlayerEntity player) { + increase(player, STAT_MANUAL_PEAK_TOTAL, 1); + } + + public static void incrementFillBottle(ServerPlayerEntity player) { + increase(player, STAT_FILL_BOTTLE_TOTAL, 1); + } + + public static int getBottlesFilled(ServerPlayerEntity player) { + return get(player, STAT_FILL_BOTTLE_TOTAL); + } + + public static void incrementPregnancyStarted(ServerPlayerEntity player) { + increase(player, STAT_PREGNANCY_STARTED_TOTAL, 1); + } + + public static void incrementPregnancyCompleted(ServerPlayerEntity player) { + increase(player, STAT_PREGNANCY_COMPLETED_TOTAL, 1); + } + + public static int getPregnanciesCompleted(ServerPlayerEntity player) { + return get(player, STAT_PREGNANCY_COMPLETED_TOTAL); + } + + public static void addOffspringSpawned(ServerPlayerEntity player, Identifier entityTypeId, int amount) { + if (player != null && amount > 0) { + increase(player, STAT_OFFSPRING_SPAWNED_TOTAL, amount); + if (entityTypeId != null) { + ensureEntityStats(entityTypeId); + Identifier stat = OFFSPRING_SPAWNED_BY_ENTITY.get(entityTypeId); + if (stat != null) { + increase(player, stat, amount); + } + } + } + } + + public static void incrementPeakedAnimations(ServerPlayerEntity player, Set entityTypes) { + increase(player, STAT_PEAKED_TOTAL, 1); + if (entityTypes != null && !entityTypes.isEmpty()) { + for (Identifier entityTypeId : entityTypes) { + if (entityTypeId != null) { + ensureEntityStats(entityTypeId); + Identifier stat = PEAKED_BY_ENTITY.get(entityTypeId); + if (stat != null) { + increase(player, stat, 1); + } + } + } + } + } + + public static void addLiquidGained(ServerPlayerEntity player, int amount, Map perEntityAmounts) { + if (amount > 0) { + increase(player, STAT_LIQUID_GAINED_TOTAL, amount); + if (perEntityAmounts != null && !perEntityAmounts.isEmpty()) { + for (Entry entry : perEntityAmounts.entrySet()) { + Identifier entityId = entry.getKey(); + int entityAmount = entry.getValue() == null ? 0 : entry.getValue(); + if (entityId != null && entityAmount > 0) { + ensureEntityStats(entityId); + Identifier stat = LIQUID_GAINED_BY_ENTITY.get(entityId); + if (stat != null) { + increase(player, stat, entityAmount); + } + } + } + } + } + } + + private static void increase(ServerPlayerEntity player, Identifier statId, int amount) { + if (player != null && statId != null && amount > 0) { + if (registerStat(statId, StatFormatter.DEFAULT)) { + player.increaseStat(statId, amount); + } + } + } + + private static int get(ServerPlayerEntity player, Identifier statId) { + if (player == null || statId == null) { + return 0; + } else { + return !registerStat(statId, StatFormatter.DEFAULT) ? 0 : player.getStatHandler().getStat(Stats.CUSTOM.getOrCreateStat(statId)); + } + } + + private static void ensureEntityStats(Identifier entityId) { + synchronized (NonStats.class) { + if (!PEAKED_BY_ENTITY.containsKey(entityId)) { + Identifier peakedStat = NeedsOfNature.id("peaked_animations_entity_" + sanitizeEntityId(entityId)); + if (registerStat(peakedStat, StatFormatter.DEFAULT)) { + PEAKED_BY_ENTITY.put(entityId, peakedStat); + } + } + + if (!LIQUID_GAINED_BY_ENTITY.containsKey(entityId)) { + Identifier liquidStat = NeedsOfNature.id("liquid_gained_ml_entity_" + sanitizeEntityId(entityId)); + if (registerStat(liquidStat, StatFormatter.DEFAULT)) { + LIQUID_GAINED_BY_ENTITY.put(entityId, liquidStat); + } + } + + if (!OFFSPRING_SPAWNED_BY_ENTITY.containsKey(entityId)) { + Identifier offspringStat = NeedsOfNature.id("offspring_spawned_entity_" + sanitizeEntityId(entityId)); + if (registerStat(offspringStat, StatFormatter.DEFAULT)) { + OFFSPRING_SPAWNED_BY_ENTITY.put(entityId, offspringStat); + } + } + } + } + + private static boolean registerStat(Identifier statId, StatFormatter formatter) { + if (statId == null) { + return false; + } + + if (!Registries.CUSTOM_STAT.containsId(statId)) { + try { + Registry.register(Registries.CUSTOM_STAT, statId, statId); + } catch (RuntimeException ex) { + return false; + } + } + + Stats.CUSTOM.getOrCreateStat(statId, formatter); + return true; + } + + private static String sanitizeEntityId(Identifier entityId) { + return sanitizePathPart(entityId.getNamespace()) + "_" + sanitizePathPart(entityId.getPath()); + } + + private static String sanitizePathPart(String value) { + if (value != null && !value.isBlank()) { + StringBuilder out = new StringBuilder(value.length()); + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c >= 'a' && c <= 'z') { + out.append(c); + } else if (c >= '0' && c <= '9') { + out.append(c); + } else { + out.append('_'); + } + } + + return out.isEmpty() ? "unknown" : out.toString(); + } else { + return "unknown"; + } + } +} diff --git a/src/main/java/com/nonid/NonTrinketsCompat.java b/src/main/java/com/nonid/NonTrinketsCompat.java new file mode 100644 index 0000000..db548e5 --- /dev/null +++ b/src/main/java/com/nonid/NonTrinketsCompat.java @@ -0,0 +1,466 @@ +package com.nonid; + +import com.nonid.data.NonAccessoryBehavior; +import com.nonid.data.NonAccessoryDefinitions; +import com.nonid.data.NonAccessoryItemRegistry; +import com.nonid.data.NonAccessoryStackData; +import com.nonid.data.NonLiquidRoles; +import dev.emi.trinkets.api.SlotReference; +import dev.emi.trinkets.api.TrinketComponent; +import dev.emi.trinkets.api.TrinketsApi; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.util.TriState; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.Identifier; +import net.minecraft.util.Pair; + +final class NonTrinketsCompat { + private static final Map LAST_SYNCED_EFFECTS = new ConcurrentHashMap<>(); + private static final Map> PENDING_UNEQUIPPED_SLOTS = new ConcurrentHashMap<>(); + + private NonTrinketsCompat() { + } + + static void registerPredicates() { + TrinketsApi.registerTrinketPredicate( + NeedsOfNature.id("female"), + (stack, slot, entity) -> NonAccessoryBehavior.isSlot(slot.getId(), "legs/v") && !NonGenderSystem.hasFemaleGender(entity) + ? TriState.FALSE + : TriState.DEFAULT + ); + TrinketsApi.registerTrinketPredicate( + NeedsOfNature.id("male"), + (stack, slot, entity) -> NonAccessoryBehavior.isSlot(slot.getId(), "legs/d") && !NonGenderSystem.hasMaleGender(entity) + ? TriState.FALSE + : TriState.DEFAULT + ); + } + + static void registerTrinketItem(Item item) { + if (item != null) { + TrinketsApi.registerTrinket(item, NonDataAccessoryTrinket.INSTANCE); + } + } + + static NonAccessoryEffects getGlobalAccessoryEffects(LivingEntity entity) { + return entity == null + ? NonAccessoryEffects.NEUTRAL + : TrinketsApi.getTrinketComponent(entity) + .map(TrinketComponent::getAllEquipped) + .orElse(List.of()) + .stream() + .filter(pair -> !isPendingUnequippedSlot(entity, (SlotReference)pair.getLeft())) + .filter(pair -> { + Identifier itemId = Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem()); + return !NonAccessoryShedSystem.isSlotEffectSuppressed(entity.getUuid(), ((SlotReference)pair.getLeft()).getId(), itemId); + }) + .map(pair -> Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem())) + .map(NonAccessoryDefinitions::getEffects) + .map(NonAccessoryEffects::globalOnly) + .reduce(NonAccessoryEffects.NEUTRAL, NonAccessoryEffects::combine); + } + + static NonAccessoryEffects getActiveTankAccessoryEffects(LivingEntity entity) { + if (entity == null) { + return NonAccessoryEffects.NEUTRAL; + } + + String activeSlotId = NonTrinketsIntegration.getActiveTankSlotId(entity); + return TrinketsApi.getTrinketComponent(entity) + .map(TrinketComponent::getAllEquipped) + .orElse(List.of()) + .stream() + .filter(pair -> !isPendingUnequippedSlot(entity, (SlotReference)pair.getLeft())) + .filter(pair -> NonAccessoryBehavior.isSlot(((SlotReference)pair.getLeft()).getId(), activeSlotId)) + .filter(pair -> { + Identifier itemId = Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem()); + return !NonAccessoryShedSystem.isSlotEffectSuppressed(entity.getUuid(), ((SlotReference)pair.getLeft()).getId(), itemId); + }) + .map(pair -> Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem())) + .map(NonAccessoryDefinitions::getEffects) + .map(NonAccessoryEffects::tankScopedOnly) + .reduce(NonAccessoryEffects.NEUTRAL, NonAccessoryEffects::combine); + } + + static boolean blocksPregnancy(ServerPlayerEntity player) { + if (player == null) { + return false; + } + + String activeSlotId = NonTrinketsIntegration.getActiveTankSlotId(player); + return TrinketsApi.getTrinketComponent(player) + .map(TrinketComponent::getAllEquipped) + .orElse(List.of()) + .stream() + .filter(pair -> !isPendingUnequippedSlot(player, (SlotReference)pair.getLeft())) + .anyMatch( + pair -> { + ItemStack stack = (ItemStack)pair.getRight(); + if (stack != null && !stack.isEmpty()) { + String slotId = ((SlotReference)pair.getLeft()).getId(); + if (!NonAccessoryBehavior.isSlot(slotId, activeSlotId)) { + return false; + } + + Identifier itemId = Registries.ITEM.getId(stack.getItem()); + return NonAccessoryShedSystem.isSlotEffectSuppressed(player.getUuid(), slotId, itemId) + ? false + : NonAccessoryDefinitions.getBehavior(itemId).blocksPregnancy(); + } else { + return false; + } + } + ); + } + + static boolean hasProtectorFor(ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + return player != null && role != null && role != NonLiquidRoles.InjectorRole.NONE + ? TrinketsApi.getTrinketComponent(player) + .map(TrinketComponent::getAllEquipped) + .orElse(List.of()) + .stream() + .filter(pair -> !isPendingUnequippedSlot(player, (SlotReference)pair.getLeft())) + .anyMatch(pair -> { + ItemStack stack = (ItemStack)pair.getRight(); + return stack != null && !stack.isEmpty() ? NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(stack.getItem())).blocks(role) : false; + }) + : false; + } + + static boolean consumeProtectorFor(ServerWorld world, ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + if (world != null && player != null && role != null && role != NonLiquidRoles.InjectorRole.NONE) { + TrinketComponent component = (TrinketComponent)TrinketsApi.getTrinketComponent(player).orElse(null); + if (component == null) { + return false; + } + + for (Pair pair : component.getAllEquipped()) { + if (!isPendingUnequippedSlot(player, (SlotReference)pair.getLeft())) { + ItemStack stack = (ItemStack)pair.getRight(); + if (stack != null && !stack.isEmpty()) { + NonAccessoryBehavior behavior = NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(stack.getItem())); + if (behavior.blocks(role)) { + if (Math.max(stack.getMaxDamage(), behavior.maxDurability()) <= 0) { + return false; + } + + int cost = Math.max(1, behavior.protectionDurabilityCost()); + applyDurabilityCost(world, player, (SlotReference)pair.getLeft(), stack, cost); + world.playSound( + null, + player.getX(), + player.getY(), + player.getZ(), + SoundEvents.ITEM_SHIELD_BLOCK, + SoundCategory.PLAYERS, + 0.75F, + 0.9F + world.getRandom().nextFloat() * 0.2F + ); + world.spawnParticles(ParticleTypes.CRIT, player.getX(), player.getBodyY(0.55), player.getZ(), 8, 0.25, 0.35, 0.25, 0.08); + forceAccessorySync(player); + return true; + } + } + } + } + + return false; + } else { + return false; + } + } + + static void applyInjectionDurabilityCost(ServerWorld world, ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + if (world != null && player != null && role != null) { + if (role == NonLiquidRoles.InjectorRole.V || role == NonLiquidRoles.InjectorRole.A) { + TrinketComponent component = (TrinketComponent)TrinketsApi.getTrinketComponent(player).orElse(null); + if (component != null) { + for (Pair pair : component.getAllEquipped()) { + if (!isPendingUnequippedSlot(player, (SlotReference)pair.getLeft())) { + ItemStack stack = (ItemStack)pair.getRight(); + if (stack != null && !stack.isEmpty()) { + String slotId = ((SlotReference)pair.getLeft()).getId(); + if ((role != NonLiquidRoles.InjectorRole.V || NonAccessoryBehavior.isSlot(slotId, "legs/v")) + && (role != NonLiquidRoles.InjectorRole.A || NonAccessoryBehavior.isSlot(slotId, "legs/a"))) { + NonAccessoryBehavior behavior = NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(stack.getItem())); + int cost = role == NonLiquidRoles.InjectorRole.V ? behavior.vInjectionDurabilityCost() : behavior.aInjectionDurabilityCost(); + if (cost > 0) { + applyDurabilityCost(world, player, (SlotReference)pair.getLeft(), stack, cost); + } + } + } + } + } + + forceAccessorySync(player); + } + } + } + } + + private static boolean applyDurabilityCost(ServerWorld world, ServerPlayerEntity player, SlotReference slot, ItemStack stack, int cost) { + if (world != null && player != null && slot != null && stack != null && !stack.isEmpty() && cost > 0) { + NonAccessoryBehavior behavior = NonAccessoryDefinitions.getBehavior(Registries.ITEM.getId(stack.getItem())); + int maxDamage = Math.max(stack.getMaxDamage(), behavior.maxDurability()); + if (maxDamage <= 0) { + return false; + } + + if (stack.getMaxDamage() <= 0) { + stack.set(DataComponentTypes.MAX_DAMAGE, maxDamage); + } + + if (stack.get(DataComponentTypes.DAMAGE) == null) { + stack.set(DataComponentTypes.DAMAGE, 0); + } + + stack.damage(cost, world, player, brokenItem -> TrinketsApi.onTrinketBroken(stack, slot, player)); + slot.inventory().markUpdate(); + return true; + } else { + return false; + } + } + + static void onAccessoryEquipped(LivingEntity entity, SlotReference slot) { + clearPendingUnequippedSlot(entity, slot); + if (entity instanceof ServerPlayerEntity player) { + forceAccessorySync(player); + } + } + + static void onAccessoryUnequipped(LivingEntity entity, SlotReference slot) { + markPendingUnequippedSlot(entity, slot); + if (entity instanceof ServerPlayerEntity player) { + forceAccessorySync(player); + } + } + + static void syncAccessoryStateIfChanged(ServerPlayerEntity player) { + if (player != null) { + NonAccessoryEffects effects = getGlobalAccessoryEffects(player); + effects = effects.combine(getActiveTankAccessoryEffects(player)); + NonAccessoryEffects previous = LAST_SYNCED_EFFECTS.put(player.getUuid(), effects); + if (!effects.equals(previous)) { + NeedsOfNature.syncPlayerRuntimeSettings(player); + NeedsOfNature.syncLiquidState(player); + } + } + } + + static void forceAccessorySync(ServerPlayerEntity player) { + if (player != null) { + LAST_SYNCED_EFFECTS.remove(player.getUuid()); + syncAccessoryStateIfChanged(player); + } + } + + static void clearAccessoryState(UUID playerUuid) { + if (playerUuid != null) { + LAST_SYNCED_EFFECTS.remove(playerUuid); + PENDING_UNEQUIPPED_SLOTS.remove(playerUuid); + } + } + + static void clearRuntimeState() { + LAST_SYNCED_EFFECTS.clear(); + PENDING_UNEQUIPPED_SLOTS.clear(); + } + + static List resolveEquippedSkinOverlays(LivingEntity entity) { + return entity == null + ? List.of() + : TrinketsApi.getTrinketComponent(entity) + .map( + component -> { + List overlays = new ArrayList<>(); + + for (Pair pair : component.getAllEquipped()) { + if (pair.getRight() != null && !((ItemStack)pair.getRight()).isEmpty()) { + Identifier itemId = Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem()); + if (!NonAccessoryShedSystem.isSlotVisuallyShed(entity.getUuid(), ((SlotReference)pair.getLeft()).getId(), itemId)) { + for (NonAccessoryItemRegistry.VisualLayer layer : NonAccessoryItemRegistry.getSkinOverlays(itemId) + .forSlot(((SlotReference)pair.getLeft()).getId())) { + NonTrinketsIntegration.ResolvedVisualLayer resolved = NonTrinketsIntegration.resolveVisualLayer((ItemStack)pair.getRight(), layer); + if (resolved != null) { + overlays.add(resolved); + } + } + } + } + } + + return overlays.isEmpty() ? List.of() : List.copyOf(overlays); + } + ) + .orElse(List.of()); + } + + static Set resolveVirtualActorTags(LivingEntity entity) { + ItemStack stack = getMaleRoleAccessoryStack(entity); + if (stack.isEmpty()) { + return Set.of(); + } + + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(Registries.ITEM.getId(stack.getItem())); + return definition == null ? Set.of() : definition.actorVisualBehavior().virtualActorTags(); + } + + static ItemStack getMaleRoleAccessoryStack(LivingEntity entity) { + if (entity != null && NonGenderSystem.isOnlyFemale(entity)) { + TrinketComponent component = (TrinketComponent)TrinketsApi.getTrinketComponent(entity).orElse(null); + if (component == null) { + return ItemStack.EMPTY; + } + + for (Pair pair : component.getAllEquipped()) { + ItemStack stack = (ItemStack)pair.getRight(); + if (stack != null && !stack.isEmpty() && !isPendingUnequippedSlot(entity, (SlotReference)pair.getLeft())) { + String slotId = ((SlotReference)pair.getLeft()).getId(); + if (NonAccessoryBehavior.isSlot(slotId, "legs/v")) { + Identifier itemId = Registries.ITEM.getId(stack.getItem()); + if (!NonAccessoryShedSystem.isSlotVisuallyShed(entity.getUuid(), slotId, itemId) + && !NonAccessoryShedSystem.isSlotEffectSuppressed(entity.getUuid(), slotId, itemId)) { + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(itemId); + if (definition != null && definition.actorVisualBehavior().substitutesMaleRole()) { + return stack; + } + } + } + } + } + + return ItemStack.EMPTY; + } else { + return ItemStack.EMPTY; + } + } + + static NonTrinketsIntegration.StoredInjectorLiquid peekStoredInjectorLiquid(ServerPlayerEntity player) { + ItemStack stack = getMaleRoleAccessoryStack(player); + if (stack.isEmpty()) { + return null; + } else { + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(Registries.ITEM.getId(stack.getItem())); + if (definition != null && definition.actorVisualBehavior().storedLiquidCapacityMl() > 0) { + NonAccessoryStackData.StoredLiquid liquid = NonAccessoryStackData.getStoredLiquid(stack); + return liquid == null + ? null + : new NonTrinketsIntegration.StoredInjectorLiquid( + liquid.entityTypeId(), Math.min(liquid.amountMl(), definition.actorVisualBehavior().storedLiquidCapacityMl()) + ); + } else { + return null; + } + } + } + + static NonTrinketsIntegration.StoredInjectorLiquid consumeStoredInjectorLiquid(ServerPlayerEntity player) { + NonTrinketsIntegration.StoredInjectorLiquid liquid = peekStoredInjectorLiquid(player); + if (liquid == null) { + return null; + } + + ItemStack stack = getMaleRoleAccessoryStack(player); + if (stack.isEmpty()) { + return null; + } + + NonAccessoryStackData.setStoredLiquid(stack, null, 0); + TrinketsApi.getTrinketComponent(player).ifPresent(component -> { + for (Pair pair : component.getAllEquipped()) { + if (pair.getRight() == stack) { + ((SlotReference)pair.getLeft()).inventory().markUpdate(); + break; + } + } + }); + forceAccessorySync(player); + return liquid; + } + + static List getVisuallyShedAccessoryStacks(LivingEntity entity) { + return entity == null ? List.of() : TrinketsApi.getTrinketComponent(entity).map(component -> { + List stacks = new ArrayList<>(); + + for (Pair pair : component.getAllEquipped()) { + if (pair.getRight() != null && !((ItemStack)pair.getRight()).isEmpty()) { + Identifier itemId = Registries.ITEM.getId(((ItemStack)pair.getRight()).getItem()); + if (NonAccessoryShedSystem.isSlotVisuallyShed(entity.getUuid(), ((SlotReference)pair.getLeft()).getId(), itemId)) { + stacks.add((ItemStack)pair.getRight()); + } + } + } + + return stacks.isEmpty() ? List.of() : List.copyOf(stacks); + }).orElse(List.of()); + } + + static NonTrinketsIntegration.TankScopeStatus resolveTankScopeStatus(LivingEntity entity, ItemStack stack) { + return entity != null && stack != null && !stack.isEmpty() + ? TrinketsApi.getTrinketComponent(entity) + .map( + component -> { + for (Pair pair : component.getAllEquipped()) { + if (pair.getRight() == stack) { + return NonTrinketsIntegration.isActiveTankSlot(entity, ((SlotReference)pair.getLeft()).getId()) + ? NonTrinketsIntegration.TankScopeStatus.ACTIVE_EQUIPPED + : NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED; + } + } + + return NonTrinketsIntegration.TankScopeStatus.NOT_EQUIPPED; + } + ) + .orElse(NonTrinketsIntegration.TankScopeStatus.NOT_EQUIPPED) + : NonTrinketsIntegration.TankScopeStatus.NOT_EQUIPPED; + } + + private static void markPendingUnequippedSlot(LivingEntity entity, SlotReference slot) { + String slotId = slotId(slot); + if (entity != null && slotId != null) { + PENDING_UNEQUIPPED_SLOTS.computeIfAbsent(entity.getUuid(), ignored -> ConcurrentHashMap.newKeySet()).add(slotId); + } + } + + private static void clearPendingUnequippedSlot(LivingEntity entity, SlotReference slot) { + String slotId = slotId(slot); + if (entity != null && slotId != null) { + Set slots = PENDING_UNEQUIPPED_SLOTS.get(entity.getUuid()); + if (slots != null) { + slots.remove(slotId); + if (slots.isEmpty()) { + PENDING_UNEQUIPPED_SLOTS.remove(entity.getUuid()); + } + } + } + } + + private static boolean isPendingUnequippedSlot(LivingEntity entity, SlotReference slot) { + String slotId = slotId(slot); + if (entity != null && slotId != null) { + Set slots = PENDING_UNEQUIPPED_SLOTS.get(entity.getUuid()); + return slots != null && slots.contains(slotId); + } else { + return false; + } + } + + private static String slotId(SlotReference slot) { + return slot == null ? null : slot.getId(); + } +} diff --git a/src/main/java/com/nonid/NonTrinketsIntegration.java b/src/main/java/com/nonid/NonTrinketsIntegration.java new file mode 100644 index 0000000..0cb4e2b --- /dev/null +++ b/src/main/java/com/nonid/NonTrinketsIntegration.java @@ -0,0 +1,187 @@ +package com.nonid; + +import com.nonid.data.NonAccessoryBehavior; +import com.nonid.data.NonAccessoryItemRegistry; +import com.nonid.data.NonAccessoryStackData; +import com.nonid.data.NonLiquidRoles; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonTrinketsIntegration { + private static final String TRINKETS_MOD_ID = "trinkets"; + private static final boolean TRINKETS_LOADED = FabricLoader.getInstance().isModLoaded("trinkets"); + + private NonTrinketsIntegration() { + } + + public static boolean isTrinketsLoaded() { + return TRINKETS_LOADED; + } + + public static void registerPredicates() { + if (TRINKETS_LOADED) { + NonTrinketsCompat.registerPredicates(); + } + } + + public static void registerTrinketItem(Item item) { + if (TRINKETS_LOADED) { + NonTrinketsCompat.registerTrinketItem(item); + } + } + + public static boolean isSlotAvailableFor(LivingEntity entity, String slotId) { + if (entity == null || slotId == null) { + return false; + } else if (NonAccessoryBehavior.isSlot(slotId, "legs/v")) { + return NonGenderSystem.hasFemaleGender(entity); + } else { + return NonAccessoryBehavior.isSlot(slotId, "legs/d") ? NonGenderSystem.hasMaleGender(entity) : true; + } + } + + public static NonAccessoryEffects getAccessoryEffects(LivingEntity entity) { + return getGlobalAccessoryEffects(entity); + } + + public static NonAccessoryEffects getGlobalAccessoryEffects(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.getGlobalAccessoryEffects(entity) : NonAccessoryEffects.NEUTRAL; + } + + public static NonAccessoryEffects getActiveTankAccessoryEffects(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.getActiveTankAccessoryEffects(entity) : NonAccessoryEffects.NEUTRAL; + } + + public static String getActiveTankSlotId(LivingEntity entity) { + return NonGenderSystem.getLiquidTankType(entity).trinketsSlotId(); + } + + public static boolean isActiveTankSlot(LivingEntity entity, String slotId) { + return entity != null && slotId != null ? NonAccessoryBehavior.isSlot(slotId, getActiveTankSlotId(entity)) : false; + } + + public static boolean blocksPregnancy(ServerPlayerEntity player) { + return TRINKETS_LOADED && player != null ? NonTrinketsCompat.blocksPregnancy(player) : false; + } + + public static boolean hasProtectorFor(ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + return TRINKETS_LOADED && player != null && role != null && role != NonLiquidRoles.InjectorRole.NONE + ? NonTrinketsCompat.hasProtectorFor(player, role) + : false; + } + + public static boolean consumeProtectorFor(ServerWorld world, ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + return TRINKETS_LOADED && world != null && player != null && role != null && role != NonLiquidRoles.InjectorRole.NONE + ? NonTrinketsCompat.consumeProtectorFor(world, player, role) + : false; + } + + public static void applyInjectionDurabilityCost(ServerWorld world, ServerPlayerEntity player, NonLiquidRoles.InjectorRole role) { + if (TRINKETS_LOADED && world != null && player != null && role != null) { + NonTrinketsCompat.applyInjectionDurabilityCost(world, player, role); + } + } + + public static void syncAccessoryStateIfChanged(ServerPlayerEntity player) { + if (TRINKETS_LOADED && player != null) { + NonTrinketsCompat.syncAccessoryStateIfChanged(player); + } + } + + public static void forceAccessorySync(ServerPlayerEntity player) { + if (TRINKETS_LOADED && player != null) { + NonTrinketsCompat.forceAccessorySync(player); + } + } + + public static void clearAccessoryState(UUID playerUuid) { + if (TRINKETS_LOADED && playerUuid != null) { + NonTrinketsCompat.clearAccessoryState(playerUuid); + } + } + + static void clearRuntimeState() { + if (TRINKETS_LOADED) { + NonTrinketsCompat.clearRuntimeState(); + } + } + + public static List resolveEquippedSkinOverlays(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.resolveEquippedSkinOverlays(entity) : List.of(); + } + + public static Set resolveVirtualActorTags(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.resolveVirtualActorTags(entity) : Set.of(); + } + + public static ItemStack getMaleRoleAccessoryStack(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.getMaleRoleAccessoryStack(entity) : ItemStack.EMPTY; + } + + public static boolean hasMaleRoleAccessory(LivingEntity entity) { + return !getMaleRoleAccessoryStack(entity).isEmpty(); + } + + @Nullable + public static NonTrinketsIntegration.StoredInjectorLiquid peekStoredInjectorLiquid(ServerPlayerEntity player) { + return TRINKETS_LOADED && player != null ? NonTrinketsCompat.peekStoredInjectorLiquid(player) : null; + } + + @Nullable + public static NonTrinketsIntegration.StoredInjectorLiquid consumeStoredInjectorLiquid(ServerPlayerEntity player) { + return TRINKETS_LOADED && player != null ? NonTrinketsCompat.consumeStoredInjectorLiquid(player) : null; + } + + @Nullable + public static NonTrinketsIntegration.ResolvedVisualLayer resolveVisualLayer(ItemStack stack, NonAccessoryItemRegistry.VisualLayer layer) { + if (stack != null && !stack.isEmpty() && layer != null && layer.texture() != null) { + Identifier itemId = Registries.ITEM.getId(stack.getItem()); + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(itemId); + int fallbackDye = definition == null ? 16777215 : definition.defaultDyeColor(); + + int tintRgb = switch (layer.tintMode()) { + case NONE -> 16777215; + case DYE -> NonAccessoryStackData.resolveDyeRgb(stack, fallbackDye); + case STORED_LIQUID -> { + NonAccessoryStackData.StoredLiquid liquid = NonAccessoryStackData.getStoredLiquid(stack); + yield liquid == null ? -1 : NeedsOfNature.resolveBottleLiquidTintRgb(liquid.entityTypeId()); + } + }; + return tintRgb < 0 ? null : new NonTrinketsIntegration.ResolvedVisualLayer(layer.texture(), tintRgb & 16777215); + } else { + return null; + } + } + + public static List getVisuallyShedAccessoryStacks(LivingEntity entity) { + return TRINKETS_LOADED && entity != null ? NonTrinketsCompat.getVisuallyShedAccessoryStacks(entity) : List.of(); + } + + public static NonTrinketsIntegration.TankScopeStatus resolveTankScopeStatus(LivingEntity entity, ItemStack stack) { + return TRINKETS_LOADED && entity != null && stack != null && !stack.isEmpty() + ? NonTrinketsCompat.resolveTankScopeStatus(entity, stack) + : NonTrinketsIntegration.TankScopeStatus.NOT_EQUIPPED; + } + + public record ResolvedVisualLayer(Identifier texture, int tintRgb) { + } + + public record StoredInjectorLiquid(Identifier entityTypeId, int amountMl) { + } + + public enum TankScopeStatus { + NOT_EQUIPPED, + ACTIVE_EQUIPPED, + INACTIVE_EQUIPPED; + } +} diff --git a/src/main/java/com/nonid/PregnancyCycleHolder.java b/src/main/java/com/nonid/PregnancyCycleHolder.java new file mode 100644 index 0000000..08bccae --- /dev/null +++ b/src/main/java/com/nonid/PregnancyCycleHolder.java @@ -0,0 +1,15 @@ +package com.nonid; + +public interface PregnancyCycleHolder { + boolean isPregnancyCycleInitialized(); + + long getPregnancyCycleProgressTicks(); + + long getPregnancyCycleLastGameTick(); + + long getPregnancyCycleLastTimeOfDay(); + + void setPregnancyCycleState(long var1, long var3, long var5); + + void clearPregnancyCycleState(); +} diff --git a/src/main/java/com/nonid/PregnancyCyclePhase.java b/src/main/java/com/nonid/PregnancyCyclePhase.java new file mode 100644 index 0000000..cf3ffc3 --- /dev/null +++ b/src/main/java/com/nonid/PregnancyCyclePhase.java @@ -0,0 +1,22 @@ +package com.nonid; + +public enum PregnancyCyclePhase { + SAFE, + NORMAL, + RISKY, + OVULATION; + + public static PregnancyCyclePhase fromNetworkId(int id) { + PregnancyCyclePhase[] values = values(); + return id >= 0 && id < values.length ? values[id] : NORMAL; + } + + public String displayName() { + return switch (this) { + case SAFE -> "safe"; + case NORMAL -> "normal"; + case RISKY -> "risky"; + case OVULATION -> "ovulation"; + }; + } +} diff --git a/src/main/java/com/nonid/PregnancyHolder.java b/src/main/java/com/nonid/PregnancyHolder.java new file mode 100644 index 0000000..a4aff54 --- /dev/null +++ b/src/main/java/com/nonid/PregnancyHolder.java @@ -0,0 +1,32 @@ +package com.nonid; + +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public interface PregnancyHolder { + @Nullable + Identifier getPregnantEntityTypeId(); + + long getPregnancyEndTick(); + + boolean isPregnancyPendingHatch(); + + @Nullable + NeedsOfNature.PregnancyVariantData getPregnancyVariantData(); + + int getPregnancyOffspringCount(); + + void setPregnancyOffspringCount(int var1); + + void setPregnancyState(@Nullable Identifier var1, long var2, boolean var4, @Nullable NeedsOfNature.PregnancyVariantData var5); + + default void setPregnancyState(@Nullable Identifier entityTypeId, long endTick, boolean pendingHatch) { + this.setPregnancyState(entityTypeId, endTick, pendingHatch, null); + } + + void clearPregnancyState(); + + default boolean hasPregnancyState() { + return this.getPregnantEntityTypeId() != null; + } +} diff --git a/src/main/java/com/nonid/api/NonAccessoryEffectsSnapshot.java b/src/main/java/com/nonid/api/NonAccessoryEffectsSnapshot.java new file mode 100644 index 0000000..3174b50 --- /dev/null +++ b/src/main/java/com/nonid/api/NonAccessoryEffectsSnapshot.java @@ -0,0 +1,19 @@ +package com.nonid.api; + +public record NonAccessoryEffectsSnapshot( + double liquidDecayMultiplier, + boolean equalizeLiquidDecayContext, + double playerEnergyGainMultiplier, + int liquidCapacityAdd, + double liquidGainMultiplier, + double filledEffectMultiplier, + double pregnancyChanceMultiplier, + double pregnancyDurationMultiplier, + double messGainMultiplier, + double destroyedSkinDamageMultiplier, + int attackEscapeHitsAdd, + double attackEscapeDamageMultiplier, + double playerEnergyAuraMultiplier, + double nearAnimationMobEnergyMultiplier +) { +} diff --git a/src/main/java/com/nonid/api/NonChangeSource.java b/src/main/java/com/nonid/api/NonChangeSource.java new file mode 100644 index 0000000..ff376ef --- /dev/null +++ b/src/main/java/com/nonid/api/NonChangeSource.java @@ -0,0 +1,10 @@ +package com.nonid.api; + +public enum NonChangeSource { + API, + COMMAND, + ITEM, + CLIENT_PREFERENCE, + JOIN_SELECTION, + SYSTEM; +} diff --git a/src/main/java/com/nonid/api/NonDestroyedSkinSnapshot.java b/src/main/java/com/nonid/api/NonDestroyedSkinSnapshot.java new file mode 100644 index 0000000..d151ce7 --- /dev/null +++ b/src/main/java/com/nonid/api/NonDestroyedSkinSnapshot.java @@ -0,0 +1,4 @@ +package com.nonid.api; + +public record NonDestroyedSkinSnapshot(int stage, int damage, int maxDamage) { +} diff --git a/src/main/java/com/nonid/api/NonEnergySnapshot.java b/src/main/java/com/nonid/api/NonEnergySnapshot.java new file mode 100644 index 0000000..d00cd5b --- /dev/null +++ b/src/main/java/com/nonid/api/NonEnergySnapshot.java @@ -0,0 +1,7 @@ +package com.nonid.api; + +public record NonEnergySnapshot(int energy, int maxEnergy, float passiveGainMultiplier, float passiveGainDrift, boolean initialized) { + public float fraction() { + return this.maxEnergy <= 0 ? 0.0F : Math.clamp((float)this.energy / this.maxEnergy, 0.0F, 1.0F); + } +} diff --git a/src/main/java/com/nonid/api/NonEntityApi.java b/src/main/java/com/nonid/api/NonEntityApi.java new file mode 100644 index 0000000..56dd9a8 --- /dev/null +++ b/src/main/java/com/nonid/api/NonEntityApi.java @@ -0,0 +1,45 @@ +package com.nonid.api; + +import com.nonid.EnergyHolder; +import net.minecraft.entity.LivingEntity; +import org.jetbrains.annotations.Nullable; + +public final class NonEntityApi { + private NonEntityApi() { + } + + public static boolean hasEnergy(LivingEntity entity) { + return entity instanceof EnergyHolder; + } + + @Nullable + public static NonEnergySnapshot getEnergy(LivingEntity entity) { + return entity instanceof EnergyHolder holder + ? new NonEnergySnapshot( + holder.getEnergy(), holder.getMaxEnergy(), holder.getEnergyGainMultiplier(), holder.getEnergyGainDrift(), holder.isEnergyInitialized() + ) + : null; + } + + public static boolean setEnergy(LivingEntity entity, int energy) { + if (entity instanceof EnergyHolder holder && !entity.getEntityWorld().isClient()) { + int before = holder.getEnergy(); + holder.setEnergy(energy); + return holder.getEnergy() != before; + } else { + return false; + } + } + + public static int addEnergy(LivingEntity entity, int amount) { + if (entity instanceof EnergyHolder holder && !entity.getEntityWorld().isClient() && amount != 0) { + int before = holder.getEnergy(); + long requested = (long)before + amount; + int safeRequested = requested > 2147483647L ? Integer.MAX_VALUE : (requested < -2147483648L ? Integer.MIN_VALUE : (int)requested); + holder.setEnergy(safeRequested); + return holder.getEnergy() - before; + } else { + return 0; + } + } +} diff --git a/src/main/java/com/nonid/api/NonEvents.java b/src/main/java/com/nonid/api/NonEvents.java new file mode 100644 index 0000000..26d7555 --- /dev/null +++ b/src/main/java/com/nonid/api/NonEvents.java @@ -0,0 +1,105 @@ +package com.nonid.api; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonEvents { + public static final Event PLAYER_GENDER_CHANGED = EventFactory.createArrayBacked( + NonEvents.PlayerGenderChanged.class, callbacks -> (player, gender, source) -> { + for (NonEvents.PlayerGenderChanged callback : callbacks) { + callback.onPlayerGenderChanged(player, gender, source); + } + } + ); + public static final Event LIQUID_TANK_CHANGED = EventFactory.createArrayBacked( + NonEvents.LiquidTankChanged.class, callbacks -> (player, snapshot) -> { + for (NonEvents.LiquidTankChanged callback : callbacks) { + callback.onLiquidTankChanged(player, snapshot); + } + } + ); + public static final Event MESS_CHANGED = EventFactory.createArrayBacked( + NonEvents.MessChanged.class, callbacks -> (player, snapshot) -> { + for (NonEvents.MessChanged callback : callbacks) { + callback.onMessChanged(player, snapshot); + } + } + ); + public static final Event DESTROYED_SKIN_CHANGED = EventFactory.createArrayBacked( + NonEvents.DestroyedSkinChanged.class, callbacks -> (player, snapshot) -> { + for (NonEvents.DestroyedSkinChanged callback : callbacks) { + callback.onDestroyedSkinChanged(player, snapshot); + } + } + ); + public static final Event PREGNANCY_CHANGED = EventFactory.createArrayBacked( + NonEvents.PregnancyChanged.class, callbacks -> (player, snapshot) -> { + for (NonEvents.PregnancyChanged callback : callbacks) { + callback.onPregnancyChanged(player, snapshot); + } + } + ); + public static final Event MODIFY_LIQUID_GAIN = EventFactory.createArrayBacked( + NonEvents.ModifyLiquidGain.class, callbacks -> (player, sourceEntityType, amountMl) -> { + int result = Math.max(0, amountMl); + + for (NonEvents.ModifyLiquidGain callback : callbacks) { + result = Math.max(0, callback.modifyLiquidGain(player, sourceEntityType, result)); + } + + return result; + } + ); + public static final Event ALLOW_PREGNANCY = EventFactory.createArrayBacked( + NonEvents.AllowPregnancy.class, callbacks -> (player, entityTypeId) -> { + for (NonEvents.AllowPregnancy callback : callbacks) { + if (!callback.allowPregnancy(player, entityTypeId)) { + return false; + } + } + + return true; + } + ); + + private NonEvents() { + } + + @FunctionalInterface + public interface AllowPregnancy { + boolean allowPregnancy(ServerPlayerEntity var1, Identifier var2); + } + + @FunctionalInterface + public interface DestroyedSkinChanged { + void onDestroyedSkinChanged(ServerPlayerEntity var1, NonDestroyedSkinSnapshot var2); + } + + @FunctionalInterface + public interface LiquidTankChanged { + void onLiquidTankChanged(ServerPlayerEntity var1, NonLiquidTankSnapshot var2); + } + + @FunctionalInterface + public interface MessChanged { + void onMessChanged(ServerPlayerEntity var1, NonMessSnapshot var2); + } + + @FunctionalInterface + public interface ModifyLiquidGain { + int modifyLiquidGain(ServerPlayerEntity var1, @Nullable Identifier var2, int var3); + } + + @FunctionalInterface + public interface PlayerGenderChanged { + void onPlayerGenderChanged(ServerPlayerEntity var1, NonGender var2, NonChangeSource var3); + } + + @FunctionalInterface + public interface PregnancyChanged { + void onPregnancyChanged(ServerPlayerEntity var1, NonPregnancySnapshot var2); + } +} diff --git a/src/main/java/com/nonid/api/NonGender.java b/src/main/java/com/nonid/api/NonGender.java new file mode 100644 index 0000000..e3703dc --- /dev/null +++ b/src/main/java/com/nonid/api/NonGender.java @@ -0,0 +1,27 @@ +package com.nonid.api; + +public enum NonGender { + NONE(0), + MALE(1), + FEMALE(2), + BOTH(3); + + private final int mask; + + NonGender(int mask) { + this.mask = mask; + } + + public int mask() { + return this.mask; + } + + public static NonGender fromMask(int mask) { + return switch (mask & 3) { + case 1 -> MALE; + case 2 -> FEMALE; + case 3 -> BOTH; + default -> NONE; + }; + } +} diff --git a/src/main/java/com/nonid/api/NonLiquidComposition.java b/src/main/java/com/nonid/api/NonLiquidComposition.java new file mode 100644 index 0000000..966d28d --- /dev/null +++ b/src/main/java/com/nonid/api/NonLiquidComposition.java @@ -0,0 +1,7 @@ +package com.nonid.api; + +public enum NonLiquidComposition { + EMPTY, + MIXED, + ENTITY; +} diff --git a/src/main/java/com/nonid/api/NonLiquidTankSnapshot.java b/src/main/java/com/nonid/api/NonLiquidTankSnapshot.java new file mode 100644 index 0000000..71a46ad --- /dev/null +++ b/src/main/java/com/nonid/api/NonLiquidTankSnapshot.java @@ -0,0 +1,10 @@ +package com.nonid.api; + +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonLiquidTankSnapshot(int storedMl, int capacityMl, NonLiquidComposition composition, @Nullable Identifier entityTypeId, int tintRgb) { + public float fillRatio() { + return this.capacityMl <= 0 ? 0.0F : Math.max(0.0F, Math.min(1.0F, (float)this.storedMl / this.capacityMl)); + } +} diff --git a/src/main/java/com/nonid/api/NonMessSnapshot.java b/src/main/java/com/nonid/api/NonMessSnapshot.java new file mode 100644 index 0000000..31b4c06 --- /dev/null +++ b/src/main/java/com/nonid/api/NonMessSnapshot.java @@ -0,0 +1,7 @@ +package com.nonid.api; + +public record NonMessSnapshot(int v, int a, int m) { + public int total() { + return Math.max(0, this.v) + Math.max(0, this.a) + Math.max(0, this.m); + } +} diff --git a/src/main/java/com/nonid/api/NonMessType.java b/src/main/java/com/nonid/api/NonMessType.java new file mode 100644 index 0000000..8b56929 --- /dev/null +++ b/src/main/java/com/nonid/api/NonMessType.java @@ -0,0 +1,7 @@ +package com.nonid.api; + +public enum NonMessType { + V, + A, + M; +} diff --git a/src/main/java/com/nonid/api/NonPlayerApi.java b/src/main/java/com/nonid/api/NonPlayerApi.java new file mode 100644 index 0000000..5bd885b --- /dev/null +++ b/src/main/java/com/nonid/api/NonPlayerApi.java @@ -0,0 +1,100 @@ +package com.nonid.api; + +import com.nonid.NonApiInternals; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonPlayerApi { + private NonPlayerApi() { + } + + public static NonPlayerStateSnapshot getState(PlayerEntity player) { + return NonApiInternals.snapshotPlayerState(player); + } + + public static NonGender getGender(PlayerEntity player) { + return NonApiInternals.getGender(player); + } + + public static boolean setGender(ServerPlayerEntity player, NonGender gender, NonChangeSource source) { + return NonApiInternals.setGender(player, gender, source); + } + + public static boolean canChangeGender(ServerPlayerEntity player, NonChangeSource source) { + return NonApiInternals.canChangeGender(player, source); + } + + public static NonLiquidTankSnapshot getLiquidTank(PlayerEntity player) { + return NonApiInternals.snapshotLiquid(player); + } + + public static int addLiquid(ServerPlayerEntity player, @Nullable Identifier sourceEntityType, int amountMl) { + return NonApiInternals.addLiquid(player, sourceEntityType, amountMl); + } + + public static int drainLiquid(ServerPlayerEntity player, int amountMl) { + return NonApiInternals.drainLiquid(player, amountMl); + } + + public static NonMessSnapshot getMess(PlayerEntity player) { + return NonApiInternals.snapshotMess(player); + } + + public static boolean addMess(ServerPlayerEntity player, NonMessType type, int amount) { + return NonApiInternals.addMess(player, type, amount); + } + + public static boolean cleanMess(ServerPlayerEntity player, NonMessType type, int amount) { + return NonApiInternals.cleanMess(player, type, amount); + } + + public static boolean clearMess(ServerPlayerEntity player) { + return NonApiInternals.clearMess(player); + } + + public static NonDestroyedSkinSnapshot getDestroyedSkin(PlayerEntity player) { + return NonApiInternals.snapshotDestroyedSkin(player); + } + + public static boolean damageDestroyedSkin(ServerPlayerEntity player, int amount) { + return NonApiInternals.damageDestroyedSkin(player, amount); + } + + public static boolean repairDestroyedSkin(ServerPlayerEntity player, int amount) { + return NonApiInternals.repairDestroyedSkin(player, amount); + } + + public static NonPregnancySnapshot getPregnancy(PlayerEntity player) { + return NonApiInternals.snapshotPregnancy(player); + } + + public static boolean beginPregnancy(ServerPlayerEntity player, Identifier entityTypeId, long endTick) { + return NonApiInternals.beginPregnancy(player, entityTypeId, endTick); + } + + public static boolean clearPregnancy(ServerPlayerEntity player) { + return NonApiInternals.clearPregnancy(player); + } + + public static NonAccessoryEffectsSnapshot getAccessoryEffects(PlayerEntity player) { + return NonApiInternals.snapshotAccessoryEffects(player); + } + + public static boolean isMessSystemEnabled() { + return NonApiInternals.isMessSystemEnabled(); + } + + public static boolean isDestroyedSkinSystemEnabled() { + return NonApiInternals.isDestroyedSkinSystemEnabled(); + } + + public static boolean isLiquidTankEnabled() { + return NonApiInternals.isLiquidTankEnabled(); + } + + public static boolean isPregnancyEnabled() { + return NonApiInternals.isPregnancyEnabled(); + } +} diff --git a/src/main/java/com/nonid/api/NonPlayerInteractionApi.java b/src/main/java/com/nonid/api/NonPlayerInteractionApi.java new file mode 100644 index 0000000..889516d --- /dev/null +++ b/src/main/java/com/nonid/api/NonPlayerInteractionApi.java @@ -0,0 +1,155 @@ +package com.nonid.api; + +import com.nonid.NeedsOfNature; +import com.nonid.NonPlayerInteractionProfiles; +import com.nonid.NonPlayerInteractionSystem; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import org.jetbrains.annotations.Nullable; + +public final class NonPlayerInteractionApi { + private NonPlayerInteractionApi() { + } + + @Nullable + public static NonPlayerInteractionApi.Profile getProfile(ServerPlayerEntity owner) { + ServerWorld world = serverWorld(owner); + if (world == null) { + return null; + } + + NonPlayerInteractionProfiles.Snapshot snapshot = NonPlayerInteractionProfiles.get(world).snapshot(owner.getUuid(), allowedMask()); + return new NonPlayerInteractionApi.Profile( + toApi(snapshot.defaultMode()), + snapshot.overrides().stream().map(entry -> new NonPlayerInteractionApi.Override(entry.uuid(), entry.name(), toApi(entry.mode()))).toList(), + snapshot.knownPlayers().stream().map(entry -> new NonPlayerInteractionApi.KnownPlayer(entry.uuid(), entry.name())).toList() + ); + } + + @Nullable + public static NonPlayerInteractionApi.Mode getResolvedMode(ServerPlayerEntity owner, UUID otherPlayer) { + ServerWorld world = serverWorld(owner); + return world != null && otherPlayer != null + ? toApi(NonPlayerInteractionProfiles.get(world).resolveMode(owner.getUuid(), otherPlayer, allowedMask())) + : null; + } + + public static boolean canRequest(ServerPlayerEntity requester, ServerPlayerEntity target) { + NonPlayerInteractionApi.Mode mode = target != null && requester != null ? getResolvedMode(target, requester.getUuid()) : null; + return mode != null && mode.acceptsRequests(); + } + + public static boolean canAttack(ServerPlayerEntity attacker, ServerPlayerEntity target) { + NonPlayerInteractionApi.Mode mode = target != null && attacker != null ? getResolvedMode(target, attacker.getUuid()) : null; + return mode != null && mode.acceptsAttacks(); + } + + public static boolean setDefaultMode(ServerPlayerEntity owner, NonPlayerInteractionApi.Mode mode) { + ServerWorld world = serverWorld(owner); + if (world != null && mode != null) { + NonPlayerInteractionProfiles.get(world).setDefaultMode(owner.getUuid(), toInternal(mode), allowedMask()); + sync(owner); + return true; + } else { + return false; + } + } + + public static boolean setOverride(ServerPlayerEntity owner, ServerPlayerEntity otherPlayer, NonPlayerInteractionApi.Mode mode) { + return otherPlayer == null ? false : setOverride(owner, otherPlayer.getUuid(), otherPlayer.getGameProfile().name(), mode); + } + + public static boolean setOverride(ServerPlayerEntity owner, UUID otherPlayer, String otherPlayerName, NonPlayerInteractionApi.Mode mode) { + ServerWorld world = serverWorld(owner); + if (world != null && otherPlayer != null && mode != null && !otherPlayer.equals(owner.getUuid())) { + NonPlayerInteractionProfiles.get(world).setOverride(owner.getUuid(), otherPlayer, otherPlayerName, toInternal(mode), allowedMask()); + sync(owner); + return true; + } else { + return false; + } + } + + public static boolean removeOverride(ServerPlayerEntity owner, UUID otherPlayer) { + ServerWorld world = serverWorld(owner); + if (world != null && otherPlayer != null) { + NonPlayerInteractionProfiles.get(world).removeOverride(owner.getUuid(), otherPlayer); + sync(owner); + return true; + } else { + return false; + } + } + + @Nullable + private static ServerWorld serverWorld(@Nullable ServerPlayerEntity player) { + return player != null && player.getEntityWorld() instanceof ServerWorld world ? world : null; + } + + private static int allowedMask() { + return NeedsOfNature.getConfig().getAllowedPlayerInteractionProfilesMask(); + } + + private static void sync(ServerPlayerEntity owner) { + NonPlayerInteractionSystem.sendProfileSnapshot(owner, ""); + } + + private static NonPlayerInteractionApi.Mode toApi(NonPlayerInteractionProfiles.Mode mode) { + return switch (mode) { + case DENY_ALL -> NonPlayerInteractionApi.Mode.DENY_ALL; + case REQUESTS_ONLY -> NonPlayerInteractionApi.Mode.REQUESTS_ONLY; + case REQUESTS_AND_ATTACKS -> NonPlayerInteractionApi.Mode.REQUESTS_AND_ATTACKS; + case AUTO_ACCEPT -> NonPlayerInteractionApi.Mode.AUTO_ACCEPT; + }; + } + + private static NonPlayerInteractionProfiles.Mode toInternal(NonPlayerInteractionApi.Mode mode) { + return switch (mode) { + case DENY_ALL -> NonPlayerInteractionProfiles.Mode.DENY_ALL; + case REQUESTS_ONLY -> NonPlayerInteractionProfiles.Mode.REQUESTS_ONLY; + case REQUESTS_AND_ATTACKS -> NonPlayerInteractionProfiles.Mode.REQUESTS_AND_ATTACKS; + case AUTO_ACCEPT -> NonPlayerInteractionProfiles.Mode.AUTO_ACCEPT; + }; + } + + public record KnownPlayer(UUID playerUuid, String playerName) { + } + + public enum Mode { + DENY_ALL, + REQUESTS_ONLY, + REQUESTS_AND_ATTACKS, + AUTO_ACCEPT; + + public String id() { + return this.name().toLowerCase(Locale.ROOT); + } + + public boolean acceptsRequests() { + return this != DENY_ALL; + } + + public boolean automaticallyAcceptsRequests() { + return this == AUTO_ACCEPT; + } + + public boolean acceptsAttacks() { + return this == REQUESTS_AND_ATTACKS || this == AUTO_ACCEPT; + } + } + + public record Override(UUID playerUuid, String playerName, NonPlayerInteractionApi.Mode mode) { + } + + public record Profile( + NonPlayerInteractionApi.Mode defaultMode, List overrides, List knownPlayers + ) { + public Profile { + overrides = List.copyOf(overrides); + knownPlayers = List.copyOf(knownPlayers); + } + } +} diff --git a/src/main/java/com/nonid/api/NonPlayerStateSnapshot.java b/src/main/java/com/nonid/api/NonPlayerStateSnapshot.java new file mode 100644 index 0000000..f7e3d3e --- /dev/null +++ b/src/main/java/com/nonid/api/NonPlayerStateSnapshot.java @@ -0,0 +1,11 @@ +package com.nonid.api; + +public record NonPlayerStateSnapshot( + NonGender gender, + NonLiquidTankSnapshot liquidTank, + NonMessSnapshot mess, + NonDestroyedSkinSnapshot destroyedSkin, + NonPregnancySnapshot pregnancy, + NonAccessoryEffectsSnapshot accessoryEffects +) { +} diff --git a/src/main/java/com/nonid/api/NonPregnancySnapshot.java b/src/main/java/com/nonid/api/NonPregnancySnapshot.java new file mode 100644 index 0000000..d14df90 --- /dev/null +++ b/src/main/java/com/nonid/api/NonPregnancySnapshot.java @@ -0,0 +1,10 @@ +package com.nonid.api; + +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonPregnancySnapshot(@Nullable Identifier entityTypeId, long endTick, boolean pendingHatch) { + public boolean active() { + return this.entityTypeId != null; + } +} diff --git a/src/main/java/com/nonid/api/animation/NonActorEvents.java b/src/main/java/com/nonid/api/animation/NonActorEvents.java new file mode 100644 index 0000000..b03e978 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonActorEvents.java @@ -0,0 +1,55 @@ +package com.nonid.api.animation; + +import java.util.LinkedHashSet; +import java.util.Set; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.Entity; +import org.jetbrains.annotations.NotNull; + +public final class NonActorEvents { + public static final Event PROVIDE = EventFactory.createArrayBacked(NonActorEvents.Provider.class, callbacks -> entity -> { + if (callbacks.length != 0 && entity != null) { + LinkedHashSet tags = new LinkedHashSet<>(); + + for (NonActorEvents.Provider callback : callbacks) { + Set provided = callback.provide(entity); + if (provided != null && !provided.isEmpty()) { + for (String tag : provided) { + if (tag != null && !tag.isBlank()) { + tags.add(tag); + } + } + } + } + + return tags.isEmpty() ? Set.of() : Set.copyOf(tags); + } else { + return Set.of(); + } + }); + + private NonActorEvents() { + } + + public static Set resolve(Entity entity) { + if (entity == null) { + return Set.of(); + } + + Set commandTags = entity.getCommandTags(); + Set provided = ((NonActorEvents.Provider)PROVIDE.invoker()).provide(entity); + if (provided.isEmpty()) { + return commandTags; + } + + LinkedHashSet tags = new LinkedHashSet<>(commandTags); + tags.addAll(provided); + return Set.copyOf(tags); + } + + @FunctionalInterface + public interface Provider { + Set provide(Entity var1); + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationActor.java b/src/main/java/com/nonid/api/animation/NonAnimationActor.java new file mode 100644 index 0000000..66783bf --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationActor.java @@ -0,0 +1,31 @@ +package com.nonid.api.animation; + +import java.util.Set; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationActor( + Set entityTypes, + @Nullable String entityVariant, + Set requiredTags, + Set requiredAnyTags, + String label, + boolean baby, + NonAnimationActor.Activity activity, + @Nullable Identifier leftPropItemId, + @Nullable Identifier rightPropItemId, + @Nullable Identifier floorPropItemId +) { + public NonAnimationActor { + entityTypes = entityTypes == null ? Set.of() : Set.copyOf(entityTypes); + requiredTags = requiredTags == null ? Set.of() : Set.copyOf(requiredTags); + requiredAnyTags = requiredAnyTags == null ? Set.of() : Set.copyOf(requiredAnyTags); + label = label == null ? "" : label; + activity = activity == null ? NonAnimationActor.Activity.ACTIVE : activity; + } + + public enum Activity { + ACTIVE, + PASSIVE; + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationApi.java b/src/main/java/com/nonid/api/animation/NonAnimationApi.java new file mode 100644 index 0000000..6fc5b32 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationApi.java @@ -0,0 +1,358 @@ +package com.nonid.api.animation; + +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.api.NonAnimationViews; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.server.ServerAnimationController; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationApi { + private NonAnimationApi() { + } + + public static List getLoadedDefinitions() { + return AnimationDefinitions.getLoadedDefinitionsSnapshot().stream().map(NonAnimationApi::definitionView).toList(); + } + + @Nullable + public static NonAnimationDefinition getDefinition(Identifier animationId) { + return definitionView(AnimationDefinitions.getDefinition(animationId)); + } + + public static boolean isEnabled(Identifier animationId) { + return AnimationDefinitions.isAnimationEnabled(animationId); + } + + public static boolean hasAnimationForEntityType(@Nullable EntityType type, boolean baby) { + return AnimationService.hasEnabledGenericAnimationForEntityType(type, baby); + } + + public static List findCandidates(ServerWorld world, List actors, @Nullable NonAnimationApi.MatchOptions options) { + if (world != null && actors != null && !actors.isEmpty()) { + NonAnimationApi.MatchOptions safe = options == null ? NonAnimationApi.MatchOptions.defaults() : options; + AnimationService.MatchOptions internalOptions = internalOptions(safe); + List result = new ArrayList<>(); + + for (AnimationService.EligibleAnimation candidate : AnimationService.findEligibleAnimationsForActors(world, actors, internalOptions)) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(candidate.animationId()); + if (definition != null) { + boolean eligible = AnimationService.isAnimationStartEligible(world, definition, actors, candidate.actorKeys(), safe.positionAnchor()); + result.add(new NonAnimationCandidate(definitionView(definition), candidate.actorKeys(), eligible)); + } + } + + result.sort(Comparator.comparing(candidatex -> candidatex.definition().id().toString())); + return List.copyOf(result); + } else { + return List.of(); + } + } + + @Nullable + public static NonAnimationCandidate findMatch(ServerWorld world, List actors, @Nullable NonAnimationApi.MatchOptions options) { + if (world != null && actors != null && !actors.isEmpty()) { + NonAnimationApi.MatchOptions safe = options == null ? NonAnimationApi.MatchOptions.defaults() : options; + AnimationService.MatchedAnimation match = AnimationService.findAnimationForActors(world, actors, internalOptions(safe)); + if (match == null) { + return null; + } + + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(match.animationId()); + if (definition == null) { + return null; + } + + boolean eligible = AnimationService.isAnimationStartEligible(world, definition, actors, match.actorKeys(), safe.positionAnchor()); + return new NonAnimationCandidate(definitionView(definition), match.actorKeys(), eligible); + } else { + return null; + } + } + + public static boolean canStart( + ServerWorld world, Identifier animationId, List actors, @Nullable List actorKeys, @Nullable Entity positionAnchor + ) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(animationId); + if (definition == null) { + return false; + } else { + return actorKeys == null + ? AnimationService.isAnimationStartEligible(world, definition, actors, positionAnchor) + : AnimationService.isAnimationStartEligible(world, definition, actors, actorKeys, positionAnchor); + } + } + + @Nullable + public static UUID start( + ServerWorld world, + Identifier animationId, + List actors, + @Nullable List actorKeys, + @Nullable NonAnimationApi.StartOptions options + ) { + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(animationId); + if (definition == null) { + return null; + } + + NonAnimationApi.StartOptions safe = options == null ? NonAnimationApi.StartOptions.defaults() : options; + return AnimationService.startAnimationNow( + world, + animationId, + actors, + actorKeys, + definition.stages(), + new AnimationService.StartOptions( + safe.damageBehavior(), + safe.ignoreAttackers(), + safe.positionAnchor(), + safe.requester(), + safe.metadata(), + safe.forceDebugChat(), + safe.allowMultiplePlayers() + ) + ); + } + + @Nullable + public static UUID startMatched( + ServerWorld world, + List actors, + @Nullable NonAnimationApi.MatchOptions matchOptions, + @Nullable NonAnimationApi.StartOptions startOptions + ) { + NonAnimationCandidate candidate = findMatch(world, actors, matchOptions); + return candidate != null && candidate.startEligible() ? start(world, candidate.definition().id(), actors, candidate.actorKeys(), startOptions) : null; + } + + @Nullable + public static NonAnimationSession getSession(ServerWorld world, UUID instanceId) { + if (world != null && instanceId != null) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + return snapshot == null ? null : NonAnimationViews.session(snapshot); + } else { + return null; + } + } + + @Nullable + public static NonAnimationStage getCurrentStage(ServerWorld world, UUID instanceId) { + return NonAnimationViews.stage(AnimationService.getCurrentStage(world, instanceId)); + } + + public static boolean advanceStage(ServerWorld world, UUID instanceId) { + return AnimationService.advanceStage(world, instanceId); + } + + public static boolean advanceToStage(ServerWorld world, UUID instanceId, int stageNumber) { + return AnimationService.advanceToStage(world, instanceId, stageNumber); + } + + public static void multiplySpeed(ServerWorld world, UUID instanceId, double multiplier) { + AnimationService.adjustSpeedSilently(world, instanceId, multiplier); + } + + public static boolean stop(ServerWorld world, UUID instanceId) { + return AnimationService.stopAnimation(world, instanceId); + } + + public static void stopAll(ServerWorld world) { + AnimationService.stopAll(world); + } + + public static boolean enqueue( + ServerWorld world, Identifier animationId, List actors, int insertIndex, @Nullable NonAnimationApi.StartOptions options + ) { + NonAnimationApi.StartOptions safe = options == null ? NonAnimationApi.StartOptions.defaults() : options; + return AnimationService.enqueueAnimation( + world, animationId, actors, insertIndex, safe.damageBehavior(), safe.ignoreAttackers(), safe.metadata(), safe.requester() + ); + } + + public static void clearQueue(ServerWorld world, ServerPlayerEntity player) { + AnimationService.clearQueuedAnimations(world, player); + } + + public static boolean isActorPendingOrActive(ServerWorld world, UUID actorUuid) { + return AnimationService.isActorPendingOrActive(world, actorUuid); + } + + public static boolean shouldIgnoreAttackers(ServerWorld world, LivingEntity actor) { + return AnimationService.shouldIgnoreAttackers(world, actor); + } + + public static void setStartProtection(ServerPlayerEntity player, int ticks) { + AnimationService.setAnimationStartProtection(player, ticks); + } + + public static void clearStartProtection(ServerPlayerEntity player) { + AnimationService.clearAnimationStartProtection(player); + } + + public static boolean hasStartProtection(ServerWorld world, ServerPlayerEntity player) { + return AnimationService.hasAnimationStartProtection(world, player); + } + + private static AnimationService.MatchOptions internalOptions(NonAnimationApi.MatchOptions options) { + AnimationService.AnimationCandidateFilter filter = options.candidateFilter() == null + ? null + : (world, definition, actors) -> options.candidateFilter().test(world, definitionView(definition), actors); + return new AnimationService.MatchOptions( + options.requiredAnimationTags(), + options.positionAnchor(), + options.requireStartEligibility(), + options.requiredActiveActor(), + options.excludedAnimationIds(), + filter, + options.joinPredecessorAnimationId() + ); + } + + @Nullable + private static NonAnimationDefinition definitionView(@Nullable AnimationDefinitions.Definition definition) { + return definition == null + ? null + : new NonAnimationDefinition( + definition.id(), + definition.displayName(), + definition.actors().stream().map(NonAnimationApi::actorView).toList(), + definition.requiredUnionTags(), + definition.animationTags(), + definition.contentTags(), + NonAnimationViews.stages(definition.stages()), + definition.weight(), + definition.attackEligible(), + blockView(definition.blockRequirements()), + definition.waterRequirement().name().toLowerCase(Locale.ROOT), + definition.positionAnchorActor(), + packView(definition.packInfo()), + definition.joinAfter(), + AnimationDefinitions.isDefinitionEnabled(definition) + ); + } + + private static NonAnimationActor actorView(AnimationDefinitions.ActorConstraint actor) { + return new NonAnimationActor( + actor.entityTypes(), + actor.entityVariant(), + actor.requiredTags(), + actor.requiredTagsAny(), + actor.label(), + actor.ageRequirement() == AnimationDefinitions.AgeRequirement.BABY, + actor.activity() == AnimationDefinitions.ActorActivity.PASSIVE ? NonAnimationActor.Activity.PASSIVE : NonAnimationActor.Activity.ACTIVE, + actor.propLeftItemId(), + actor.propRightItemId(), + actor.propFloorItemId() + ); + } + + @Nullable + private static NonAnimationPack packView(@Nullable AnimationDefinitions.AnimationPackInfo pack) { + return pack == null + ? null + : new NonAnimationPack( + pack.id(), + pack.resourcePackId(), + pack.name(), + pack.author(), + pack.version(), + pack.description(), + pack.localModelRoot(), + pack.modelImports() + .stream() + .map(modelImport -> new NonAnimationPack.ModelImport(modelImport.packId(), modelImport.modelRoot(), modelImport.entityTypes())) + .toList() + ); + } + + @Nullable + private static NonAnimationBlockRequirement blockView(@Nullable AnimationDefinitions.BlockRequirements requirement) { + if (requirement == null) { + return null; + } + + AnimationDefinitions.BlockPredicate blocks = requirement.blocks(); + return new NonAnimationBlockRequirement( + requirement.type(), + requirement.height() == null ? null : new NonAnimationBlockRequirement.WallHeight(requirement.height().min(), requirement.height().max()), + requirement.width() == null ? null : new NonAnimationBlockRequirement.WallWidth(requirement.width().min(), requirement.width().max()), + requirement.facing() == null ? null : requirement.facing().id(), + requirement.clearance() == null + ? null + : new NonAnimationBlockRequirement.Dimensions(requirement.clearance().width(), requirement.clearance().height(), requirement.clearance().depth()), + requirement.support() == null ? null : requirement.support().id(), + requirement.placement() == null ? null : requirement.placement().id(), + requirement.surfaceRadius(), + requirement.surfaceFootprint() == null + ? null + : new NonAnimationBlockRequirement.Footprint( + requirement.surfaceFootprint().width(), + requirement.surfaceFootprint().depth(), + requirement.surfaceFootprint().height(), + requirement.surfaceFootprint().margin() + ), + blocks == null ? Set.of() : blocks.blockIds(), + blocks == null ? Set.of() : blocks.tagIds() + ); + } + + @FunctionalInterface + public interface CandidateFilter { + boolean test(ServerWorld var1, NonAnimationDefinition var2, List var3); + } + + public record MatchOptions( + Set requiredAnimationTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + Set excludedAnimationIds, + @Nullable NonAnimationApi.CandidateFilter candidateFilter, + @Nullable Identifier joinPredecessorAnimationId + ) { + public MatchOptions { + requiredAnimationTags = requiredAnimationTags == null ? Set.of() : Set.copyOf(requiredAnimationTags); + excludedAnimationIds = excludedAnimationIds == null ? Set.of() : Set.copyOf(excludedAnimationIds); + } + + public static NonAnimationApi.MatchOptions defaults() { + return new NonAnimationApi.MatchOptions(Set.of(), null, false, null, Set.of(), null, null); + } + + public static NonAnimationApi.MatchOptions startEligible(@Nullable Entity positionAnchor) { + return new NonAnimationApi.MatchOptions(Set.of(), positionAnchor, true, null, Set.of(), null, null); + } + } + + public record StartOptions( + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + @Nullable ServerPlayerEntity requester, + Map metadata, + boolean forceDebugChat, + boolean allowMultiplePlayers + ) { + public StartOptions { + damageBehavior = damageBehavior == null ? NonAnimationDamageBehavior.IGNORE_DAMAGE : damageBehavior; + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + public static NonAnimationApi.StartOptions defaults() { + return new NonAnimationApi.StartOptions(NonAnimationDamageBehavior.IGNORE_DAMAGE, false, null, null, Map.of(), false, false); + } + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationBlockRequirement.java b/src/main/java/com/nonid/api/animation/NonAnimationBlockRequirement.java new file mode 100644 index 0000000..b9b2365 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationBlockRequirement.java @@ -0,0 +1,36 @@ +package com.nonid.api.animation; + +import java.util.Set; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationBlockRequirement( + String type, + @Nullable NonAnimationBlockRequirement.WallHeight wallHeight, + @Nullable NonAnimationBlockRequirement.WallWidth wallWidth, + @Nullable String facing, + @Nullable NonAnimationBlockRequirement.Dimensions clearance, + @Nullable String support, + @Nullable String placement, + int surfaceRadius, + @Nullable NonAnimationBlockRequirement.Footprint surfaceFootprint, + Set blockIds, + Set blockTagIds +) { + public NonAnimationBlockRequirement { + blockIds = blockIds == null ? Set.of() : Set.copyOf(blockIds); + blockTagIds = blockTagIds == null ? Set.of() : Set.copyOf(blockTagIds); + } + + public record Dimensions(int width, int height, int depth) { + } + + public record Footprint(int width, int depth, int height, int margin) { + } + + public record WallHeight(int minimum, @Nullable Integer maximum) { + } + + public record WallWidth(int minimum, @Nullable Integer maximum) { + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationCandidate.java b/src/main/java/com/nonid/api/animation/NonAnimationCandidate.java new file mode 100644 index 0000000..7eb1b94 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationCandidate.java @@ -0,0 +1,9 @@ +package com.nonid.api.animation; + +import java.util.List; + +public record NonAnimationCandidate(NonAnimationDefinition definition, List actorKeys, boolean startEligible) { + public NonAnimationCandidate { + actorKeys = actorKeys == null ? List.of() : List.copyOf(actorKeys); + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationDamageBehavior.java b/src/main/java/com/nonid/api/animation/NonAnimationDamageBehavior.java new file mode 100644 index 0000000..63234d4 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationDamageBehavior.java @@ -0,0 +1,28 @@ +package com.nonid.api.animation; + +import java.util.Locale; + +public enum NonAnimationDamageBehavior { + STOP_ON_DAMAGE, + IGNORE_DAMAGE, + BLOCK_DAMAGE; + + public String id() { + return this.name().toLowerCase(Locale.ROOT); + } + + public static NonAnimationDamageBehavior fromId(String id, NonAnimationDamageBehavior fallback) { + if (id != null && !id.isBlank()) { + String norm = id.trim().toLowerCase(Locale.ROOT); + + return switch (norm) { + case "stop_on_damage" -> STOP_ON_DAMAGE; + case "ignore_damage" -> IGNORE_DAMAGE; + case "block_damage" -> BLOCK_DAMAGE; + default -> fallback; + }; + } else { + return fallback; + } + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationDefinition.java b/src/main/java/com/nonid/api/animation/NonAnimationDefinition.java new file mode 100644 index 0000000..83fca61 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationDefinition.java @@ -0,0 +1,34 @@ +package com.nonid.api.animation; + +import java.util.List; +import java.util.Set; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationDefinition( + Identifier id, + @Nullable String displayName, + List actors, + Set requiredUnionTags, + Set animationTags, + List contentTags, + List stages, + double weight, + boolean attackEligible, + @Nullable NonAnimationBlockRequirement blockRequirement, + String waterRequirement, + @Nullable String positionAnchorActor, + @Nullable NonAnimationPack pack, + Set joinAfter, + boolean enabled +) { + public NonAnimationDefinition { + actors = actors == null ? List.of() : List.copyOf(actors); + requiredUnionTags = requiredUnionTags == null ? Set.of() : Set.copyOf(requiredUnionTags); + animationTags = animationTags == null ? Set.of() : Set.copyOf(animationTags); + contentTags = contentTags == null ? List.of() : List.copyOf(contentTags); + stages = stages == null ? List.of() : List.copyOf(stages); + waterRequirement = waterRequirement == null ? "none" : waterRequirement; + joinAfter = joinAfter == null ? Set.of() : Set.copyOf(joinAfter); + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationEvents.java b/src/main/java/com/nonid/api/animation/NonAnimationEvents.java new file mode 100644 index 0000000..b2bd2d5 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationEvents.java @@ -0,0 +1,117 @@ +package com.nonid.api.animation; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationEvents { + public static final Event BEFORE_START = EventFactory.createArrayBacked( + NonAnimationEvents.BeforeStart.class, callbacks -> context -> { + for (NonAnimationEvents.BeforeStart callback : callbacks) { + if (!callback.allow(context)) { + return false; + } + } + + return true; + } + ); + public static final Event STARTED = EventFactory.createArrayBacked( + NonAnimationEvents.Started.class, callbacks -> context -> { + for (NonAnimationEvents.Started callback : callbacks) { + callback.onStarted(context); + } + } + ); + public static final Event STOPPED = EventFactory.createArrayBacked( + NonAnimationEvents.Stopped.class, callbacks -> context -> { + for (NonAnimationEvents.Stopped callback : callbacks) { + callback.onStopped(context); + } + } + ); + public static final Event STAGE_CHANGED = EventFactory.createArrayBacked( + NonAnimationEvents.StageChanged.class, callbacks -> context -> { + for (NonAnimationEvents.StageChanged callback : callbacks) { + callback.onStageChanged(context); + } + } + ); + + private NonAnimationEvents() { + } + + @FunctionalInterface + public interface BeforeStart { + boolean allow(NonAnimationEvents.BeforeStartContext var1); + } + + public record BeforeStartContext( + ServerWorld world, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + @Nullable ServerPlayerEntity requester, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + Map metadata + ) { + public BeforeStartContext { + actorUuids = List.copyOf(actorUuids); + actorKeys = List.copyOf(actorKeys); + stages = List.copyOf(stages); + metadata = Map.copyOf(metadata); + } + } + + @FunctionalInterface + public interface StageChanged { + void onStageChanged(NonAnimationEvents.StageChangedContext var1); + } + + public record StageChangedContext( + ServerWorld world, + UUID instanceId, + Identifier animationId, + List actorUuids, + List actorKeys, + long advanceTick, + @Nullable NonAnimationStage currentStage + ) { + public StageChangedContext { + actorUuids = List.copyOf(actorUuids); + actorKeys = List.copyOf(actorKeys); + } + } + + @FunctionalInterface + public interface Started { + void onStarted(NonAnimationEvents.StartedContext var1); + } + + public record StartedContext(ServerWorld world, NonAnimationSession session, @Nullable ServerPlayerEntity requester) { + } + + @FunctionalInterface + public interface Stopped { + void onStopped(NonAnimationEvents.StoppedContext var1); + } + + public record StoppedContext( + ServerWorld world, UUID instanceId, Identifier animationId, List actorUuids, List actorKeys, List stages + ) { + public StoppedContext { + actorUuids = List.copyOf(actorUuids); + actorKeys = List.copyOf(actorKeys); + stages = List.copyOf(stages); + } + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationPack.java b/src/main/java/com/nonid/api/animation/NonAnimationPack.java new file mode 100644 index 0000000..131d2df --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationPack.java @@ -0,0 +1,33 @@ +package com.nonid.api.animation; + +import java.util.List; +import java.util.Set; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationPack( + String id, + String resourcePackId, + String name, + String author, + @Nullable String version, + @Nullable String description, + @Nullable Identifier localModelRoot, + List modelImports +) { + public NonAnimationPack { + modelImports = modelImports == null ? List.of() : List.copyOf(modelImports); + } + + public NonAnimationPack( + String id, String resourcePackId, String name, String author, @Nullable String version, @Nullable String description, @Nullable Identifier localModelRoot + ) { + this(id, resourcePackId, name, author, version, description, localModelRoot, List.of()); + } + + public record ModelImport(String packId, Identifier modelRoot, Set entityTypes) { + public ModelImport { + entityTypes = entityTypes == null ? Set.of() : Set.copyOf(entityTypes); + } + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationSession.java b/src/main/java/com/nonid/api/animation/NonAnimationSession.java new file mode 100644 index 0000000..c6db032 --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationSession.java @@ -0,0 +1,22 @@ +package com.nonid.api.animation; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationSession( + UUID instanceId, + Identifier animationId, + List actorUuids, + @Nullable NonAnimationStage currentStage, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + Map metadata +) { + public NonAnimationSession { + actorUuids = actorUuids == null ? List.of() : List.copyOf(actorUuids); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } +} diff --git a/src/main/java/com/nonid/api/animation/NonAnimationStage.java b/src/main/java/com/nonid/api/animation/NonAnimationStage.java new file mode 100644 index 0000000..3d17d2b --- /dev/null +++ b/src/main/java/com/nonid/api/animation/NonAnimationStage.java @@ -0,0 +1,24 @@ +package com.nonid.api.animation; + +import java.util.List; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record NonAnimationStage( + Identifier animationId, + boolean looping, + long lengthTicks, + boolean joinsAllowed, + double speed, + Identifier playbackAnimationId, + List actorProps, + double cycleMidpointOffsetSeconds, + double cycleTicks +) { + public NonAnimationStage { + actorProps = actorProps == null ? List.of() : List.copyOf(actorProps); + } + + public record ActorProp(String actorKey, @Nullable Identifier leftItemId, @Nullable Identifier rightItemId, @Nullable Identifier floorItemId) { + } +} diff --git a/src/main/java/com/nonid/api/client/NonAnimationParticleEvents.java b/src/main/java/com/nonid/api/client/NonAnimationParticleEvents.java new file mode 100644 index 0000000..fb249f4 --- /dev/null +++ b/src/main/java/com/nonid/api/client/NonAnimationParticleEvents.java @@ -0,0 +1,59 @@ +package com.nonid.api.client; + +import java.util.UUID; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.Entity; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationParticleEvents { + public static final Event RESOLVE = EventFactory.createArrayBacked( + NonAnimationParticleEvents.ParticleResolver.class, callbacks -> context -> { + for (NonAnimationParticleEvents.ParticleResolver callback : callbacks) { + NonAnimationParticleEvents.ParticleOverride override = callback.resolve(context); + if (override != null) { + return override; + } + } + + return null; + } + ); + + private NonAnimationParticleEvents() { + } + + public record ParticleContext( + @Nullable Entity actor, + UUID actorUuid, + @Nullable Identifier animationId, + @Nullable Identifier animationResource, + @Nullable String animationKey, + Identifier particleId, + String locator, + String script, + Vec3d position, + @Nullable UUID instanceId + ) { + } + + public record ParticleOverride(@Nullable ParticleEffect particle, boolean cancel) { + public static NonAnimationParticleEvents.ParticleOverride spawn(ParticleEffect particle) { + return new NonAnimationParticleEvents.ParticleOverride(particle, false); + } + + public static NonAnimationParticleEvents.ParticleOverride skip() { + return new NonAnimationParticleEvents.ParticleOverride(null, true); + } + } + + @FunctionalInterface + public interface ParticleResolver { + @Nullable + NonAnimationParticleEvents.ParticleOverride resolve(NonAnimationParticleEvents.ParticleContext var1); + } +} diff --git a/src/main/java/com/nonid/api/client/NonAnimationRenderEvents.java b/src/main/java/com/nonid/api/client/NonAnimationRenderEvents.java new file mode 100644 index 0000000..18f5b58 --- /dev/null +++ b/src/main/java/com/nonid/api/client/NonAnimationRenderEvents.java @@ -0,0 +1,106 @@ +package com.nonid.api.client; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.ItemDisplayContext; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationRenderEvents { + public static final Event RESOLVE = EventFactory.createArrayBacked( + NonAnimationRenderEvents.ModelResolver.class, callbacks -> (entity, entityTypeId, currentModel, currentTexture) -> { + for (NonAnimationRenderEvents.ModelResolver callback : callbacks) { + NonAnimationRenderEvents.ModelOverride resolved = callback.resolve(entity, entityTypeId, currentModel, currentTexture); + if (resolved != null) { + return resolved; + } + } + + return null; + } + ); + public static final Event RESOLVE_RENDER = EventFactory.createArrayBacked( + NonAnimationRenderEvents.RenderResolver.class, callbacks -> (entity, entityTypeId, currentModel, currentTexture) -> { + for (NonAnimationRenderEvents.RenderResolver callback : callbacks) { + NonAnimationRenderEvents.RenderOverride resolved = callback.resolve(entity, entityTypeId, currentModel, currentTexture); + if (resolved != null) { + return resolved; + } + } + + return null; + } + ); + + private NonAnimationRenderEvents() { + } + + public record BoneItemProp(ItemStack stack, ItemDisplayContext displayContext) { + public BoneItemProp(ItemStack stack) { + this(stack, ItemDisplayContext.THIRD_PERSON_RIGHT_HAND); + } + + public BoneItemProp { + stack = stack == null ? ItemStack.EMPTY : stack.copy(); + displayContext = displayContext == null ? ItemDisplayContext.THIRD_PERSON_RIGHT_HAND : displayContext; + } + } + + public record ModelOverride(@Nullable Identifier model, @Nullable Identifier texture) { + } + + @FunctionalInterface + public interface ModelResolver { + @Nullable + NonAnimationRenderEvents.ModelOverride resolve(LivingEntity var1, @Nullable Identifier var2, Identifier var3, Identifier var4); + } + + public record RenderOverride( + @Nullable Identifier model, + @Nullable Identifier texture, + @Nullable List layerTextures, + @Nullable Map boneTextures, + @Nullable Map boneItems, + @Nullable Map boneVisibility, + @Nullable Map> hiddenBoneCubeIndices + ) { + public RenderOverride(@Nullable Identifier model, @Nullable Identifier texture) { + this(model, texture, null, null, null, null, null); + } + + public RenderOverride(@Nullable List layerTextures, @Nullable Map boneTextures) { + this(null, null, layerTextures, boneTextures, null, null, null); + } + + public RenderOverride( + @Nullable List layerTextures, + @Nullable Map boneTextures, + @Nullable Map boneItems + ) { + this(null, null, layerTextures, boneTextures, boneItems, null, null); + } + + public RenderOverride( + @Nullable Identifier model, + @Nullable Identifier texture, + @Nullable List layerTextures, + @Nullable Map boneTextures, + @Nullable Map boneItems, + @Nullable Map boneVisibility + ) { + this(model, texture, layerTextures, boneTextures, boneItems, boneVisibility, null); + } + } + + @FunctionalInterface + public interface RenderResolver { + @Nullable + NonAnimationRenderEvents.RenderOverride resolve(LivingEntity var1, @Nullable Identifier var2, Identifier var3, Identifier var4); + } +} diff --git a/src/main/java/com/nonid/api/client/NonAnimationSoundEvents.java b/src/main/java/com/nonid/api/client/NonAnimationSoundEvents.java new file mode 100644 index 0000000..8b396e0 --- /dev/null +++ b/src/main/java/com/nonid/api/client/NonAnimationSoundEvents.java @@ -0,0 +1,67 @@ +package com.nonid.api.client; + +import java.util.UUID; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationSoundEvents { + public static final Event RESOLVE = EventFactory.createArrayBacked( + NonAnimationSoundEvents.SoundResolver.class, callbacks -> context -> { + for (NonAnimationSoundEvents.SoundResolver callback : callbacks) { + NonAnimationSoundEvents.SoundOverride override = callback.resolve(context); + if (override != null) { + return override; + } + } + + return null; + } + ); + + private NonAnimationSoundEvents() { + } + + public record SoundContext( + LivingEntity anchor, + Identifier animationId, + Identifier animationResource, + String animationKey, + String effect, + double timeSeconds, + float volume, + float pitch, + UUID instanceId, + int stageIndex, + boolean loop, + double cueTimeTicks, + double cycleTicks, + double animationSpeed, + double nextSameEffectCueDelayTicks, + double nextSameEffectCueDelayMs + ) { + } + + public record SoundOverride(@Nullable Identifier soundId, float volume, float pitch, boolean cancel) { + public static NonAnimationSoundEvents.SoundOverride play(Identifier soundId, float volume, float pitch) { + return new NonAnimationSoundEvents.SoundOverride(soundId, volume, pitch, false); + } + + public static NonAnimationSoundEvents.SoundOverride play(Identifier soundId) { + return new NonAnimationSoundEvents.SoundOverride(soundId, 1.0F, 1.0F, false); + } + + public static NonAnimationSoundEvents.SoundOverride skip() { + return new NonAnimationSoundEvents.SoundOverride(null, 1.0F, 1.0F, true); + } + } + + @FunctionalInterface + public interface SoundResolver { + @Nullable + NonAnimationSoundEvents.SoundOverride resolve(NonAnimationSoundEvents.SoundContext var1); + } +} diff --git a/src/main/java/com/nonid/client/HorseLiquidCollectorGeoModel.java b/src/main/java/com/nonid/client/HorseLiquidCollectorGeoModel.java new file mode 100644 index 0000000..7ac890f --- /dev/null +++ b/src/main/java/com/nonid/client/HorseLiquidCollectorGeoModel.java @@ -0,0 +1,33 @@ +package com.nonid.client; + +import com.nonid.entity.HorseLiquidCollectorEntity; +import com.nonid.internal.animation.AnimationEngine; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.constant.dataticket.DataTicket; +import software.bernie.geckolib.model.GeoModel; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public class HorseLiquidCollectorGeoModel extends GeoModel { + public static final DataTicket<@NotNull Boolean> COLLECTOR_FULL_TICKET = DataTicket.create("non_horse_liquid_collector_full", Boolean.class); + public static final Identifier MODEL_ID = Identifier.of("needsofnature", "entity/horse_liquid_collector"); + public static final Identifier EMPTY_TEXTURE_ID = Identifier.of("needsofnature", "textures/entity/horse_liquid_collector/horse_liquid_collector_empty.png"); + public static final Identifier FULL_TEXTURE_ID = Identifier.of("needsofnature", "textures/entity/horse_liquid_collector/horse_liquid_collector_full.png"); + private static final Identifier PLACEHOLDER_ANIMATION_ID = AnimationEngine.packId("afw/placeholder"); + + @NotNull + public Identifier getModelResource(@NotNull GeoRenderState renderState) { + return MODEL_ID; + } + + @NotNull + public Identifier getTextureResource(GeoRenderState renderState) { + boolean full = (Boolean)renderState.getOrDefaultGeckolibData(COLLECTOR_FULL_TICKET, Boolean.FALSE); + return full ? FULL_TEXTURE_ID : EMPTY_TEXTURE_ID; + } + + @NotNull + public Identifier getAnimationResource(HorseLiquidCollectorEntity animatable) { + return PLACEHOLDER_ANIMATION_ID; + } +} diff --git a/src/main/java/com/nonid/client/HorseLiquidCollectorItemGeoModel.java b/src/main/java/com/nonid/client/HorseLiquidCollectorItemGeoModel.java new file mode 100644 index 0000000..a67ad8c --- /dev/null +++ b/src/main/java/com/nonid/client/HorseLiquidCollectorItemGeoModel.java @@ -0,0 +1,27 @@ +package com.nonid.client; + +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.item.HorseLiquidCollectorItem; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.model.GeoModel; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public class HorseLiquidCollectorItemGeoModel extends GeoModel { + private static final Identifier PLACEHOLDER_ANIMATION_ID = AnimationEngine.packId("afw/placeholder"); + + @NotNull + public Identifier getModelResource(@NotNull GeoRenderState renderState) { + return HorseLiquidCollectorGeoModel.MODEL_ID; + } + + @NotNull + public Identifier getTextureResource(@NotNull GeoRenderState renderState) { + return HorseLiquidCollectorGeoModel.EMPTY_TEXTURE_ID; + } + + @NotNull + public Identifier getAnimationResource(HorseLiquidCollectorItem animatable) { + return PLACEHOLDER_ANIMATION_ID; + } +} diff --git a/src/main/java/com/nonid/client/HorseLiquidCollectorItemRenderer.java b/src/main/java/com/nonid/client/HorseLiquidCollectorItemRenderer.java new file mode 100644 index 0000000..e0e5242 --- /dev/null +++ b/src/main/java/com/nonid/client/HorseLiquidCollectorItemRenderer.java @@ -0,0 +1,10 @@ +package com.nonid.client; + +import com.nonid.item.HorseLiquidCollectorItem; +import software.bernie.geckolib.renderer.GeoItemRenderer; + +public class HorseLiquidCollectorItemRenderer extends GeoItemRenderer { + public HorseLiquidCollectorItemRenderer() { + super(new HorseLiquidCollectorItemGeoModel()); + } +} diff --git a/src/main/java/com/nonid/client/HorseLiquidCollectorRenderer.java b/src/main/java/com/nonid/client/HorseLiquidCollectorRenderer.java new file mode 100644 index 0000000..2828b8f --- /dev/null +++ b/src/main/java/com/nonid/client/HorseLiquidCollectorRenderer.java @@ -0,0 +1,28 @@ +package com.nonid.client; + +import com.nonid.entity.HorseLiquidCollectorEntity; +import net.minecraft.client.render.OverlayTexture; +import net.minecraft.client.render.entity.EntityRendererFactory.Context; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.renderer.GeoEntityRenderer; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public class HorseLiquidCollectorRenderer extends GeoEntityRenderer { + public HorseLiquidCollectorRenderer(Context context) { + super(context, new HorseLiquidCollectorGeoModel()); + } + + public void addRenderData(HorseLiquidCollectorEntity animatable, Void relatedObject, R renderState, float partialTick) { + super.addRenderData(animatable, relatedObject, renderState, partialTick); + renderState.addGeckolibData(HorseLiquidCollectorGeoModel.COLLECTOR_FULL_TICKET, animatable.isCollectorFull()); + } + + public int getPackedOverlay(HorseLiquidCollectorEntity animatable, Void relatedObject, float u, float partialTick) { + return OverlayTexture.DEFAULT_UV; + } + + protected float getDeathMaxRotation(@NotNull GeoRenderState renderState) { + return 0.0F; + } +} diff --git a/src/main/java/com/nonid/client/NonAccessorySkinOverlays.java b/src/main/java/com/nonid/client/NonAccessorySkinOverlays.java new file mode 100644 index 0000000..09d25b3 --- /dev/null +++ b/src/main/java/com/nonid/client/NonAccessorySkinOverlays.java @@ -0,0 +1,14 @@ +package com.nonid.client; + +import com.nonid.NonTrinketsIntegration; +import java.util.List; +import net.minecraft.entity.LivingEntity; + +public final class NonAccessorySkinOverlays { + private NonAccessorySkinOverlays() { + } + + public static List resolveEquippedOverlays(LivingEntity entity) { + return NonTrinketsIntegration.resolveEquippedSkinOverlays(entity); + } +} diff --git a/src/main/java/com/nonid/client/NonAccessoryTooltips.java b/src/main/java/com/nonid/client/NonAccessoryTooltips.java new file mode 100644 index 0000000..2ba5f69 --- /dev/null +++ b/src/main/java/com/nonid/client/NonAccessoryTooltips.java @@ -0,0 +1,270 @@ +package com.nonid.client; + +import com.nonid.NonAccessoryEffects; +import com.nonid.NonTrinketsIntegration; +import com.nonid.data.NonAccessoryBehavior; +import com.nonid.data.NonAccessoryDefinitions; +import com.nonid.data.NonAccessoryItemRegistry; +import com.nonid.data.NonAccessoryStackData; +import com.nonid.data.NonLiquidRoles; +import java.util.List; +import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; + +public final class NonAccessoryTooltips { + private static final Text TANK_SCOPED_ICON = Text.literal("\ud83e\uddea "); + + private NonAccessoryTooltips() { + } + + public static void register() { + ItemTooltipCallback.EVENT.register((ItemTooltipCallback)(stack, context, type, lines) -> appendTooltip(stack, lines)); + } + + private static void appendTooltip(ItemStack stack, List lines) { + if (stack != null && !stack.isEmpty()) { + Identifier itemId = Registries.ITEM.getId(stack.getItem()); + if (NonAccessoryDefinitions.hasDefinition(itemId)) { + NonAccessoryEffects effects = NonAccessoryDefinitions.getEffects(itemId); + NonTrinketsIntegration.TankScopeStatus tankScope = resolveTankScopeStatus(stack); + int effectStartIndex = lines.size(); + boolean warnedInactiveTankSlot = false; + warnedInactiveTankSlot |= addLiquidDecayTooltip(lines, itemId, effects.liquidDecayMultiplier(), tankScope); + if (effects.equalizeLiquidDecayContext()) { + lines.add( + tankScopedText( + Text.translatable("item.needsofnature.accessory.tooltip.equalize_liquid_decay_context"), + itemId, + "equalize_liquid_decay_context", + true, + tankScope + ) + ); + warnedInactiveTankSlot |= tankScope == NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED; + } + + addMultiplierTooltip(lines, itemId, "player_energy_gain", "player_energy_gain_multiplier", effects.playerEnergyGainMultiplier(), false); + warnedInactiveTankSlot |= addAdditiveTooltip(lines, itemId, "liquid_capacity", "liquid_capacity_add", effects.liquidCapacityAdd(), true, tankScope); + warnedInactiveTankSlot |= addMultiplierTooltip( + lines, itemId, "liquid_gain", "liquid_gain_multiplier", effects.liquidGainMultiplier(), true, tankScope + ); + warnedInactiveTankSlot |= addMultiplierTooltip( + lines, itemId, "filled_effect", "filled_effect_multiplier", effects.filledEffectMultiplier(), false, tankScope + ); + warnedInactiveTankSlot |= addMultiplierTooltip( + lines, itemId, "pregnancy_chance", "pregnancy_chance_multiplier", effects.pregnancyChanceMultiplier(), false, tankScope + ); + warnedInactiveTankSlot |= addMultiplierTooltip( + lines, itemId, "pregnancy_duration", "pregnancy_duration_multiplier", effects.pregnancyDurationMultiplier(), false, tankScope + ); + warnedInactiveTankSlot |= addMultiplierTooltip(lines, itemId, "mess_gain", "mess_gain_multiplier", effects.messGainMultiplier(), false, tankScope); + addMultiplierTooltip(lines, itemId, "destroyed_skin_damage", "destroyed_skin_damage_multiplier", effects.destroyedSkinDamageMultiplier(), false); + addAdditiveTooltip(lines, itemId, "attack_escape_hits", "attack_escape_hits_add", effects.attackEscapeHitsAdd(), false); + addMultiplierTooltip(lines, itemId, "attack_escape_damage", "attack_escape_damage_multiplier", effects.attackEscapeDamageMultiplier(), false); + addMultiplierTooltip(lines, itemId, "player_energy_aura", "player_energy_aura_multiplier", effects.playerEnergyAuraMultiplier(), false); + addMultiplierTooltip( + lines, itemId, "near_animation_mob_energy", "near_animation_mob_energy_multiplier", effects.nearAnimationMobEnergyMultiplier(), false + ); + addBehaviorTooltips(lines, itemId, stack); + if (warnedInactiveTankSlot) { + lines.add(effectStartIndex, Text.translatable("item.needsofnature.accessory.tooltip.inactive_tank_slot").formatted(Formatting.GRAY)); + } + } + } + } + + private static void addBehaviorTooltips(List lines, Identifier itemId, ItemStack stack) { + NonAccessoryBehavior behavior = NonAccessoryDefinitions.getBehavior(itemId); + if (!behavior.blocksInjectorTypes().isEmpty()) { + boolean blocksV = behavior.blocksInjectorTypes().contains(NonLiquidRoles.InjectorRole.V); + boolean blocksA = behavior.blocksInjectorTypes().contains(NonLiquidRoles.InjectorRole.A); + if (blocksV && blocksA) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.blocks_injector_av").formatted(Formatting.GREEN)); + } else if (blocksV) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.blocks_injector_v").formatted(Formatting.GREEN)); + } else if (blocksA) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.blocks_injector_a").formatted(Formatting.GREEN)); + } + } + + if (behavior.occupiesSlots().size() > 1) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.occupies_av").formatted(Formatting.GRAY)); + } + + if (behavior.ignoreInjectorSlotEffectSuppression()) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.ignore_injector_slot_effect_suppression").formatted(Formatting.GRAY)); + } + + if (behavior.blocksPregnancy()) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.blocks_pregnancy").formatted(Formatting.GREEN)); + } + + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(itemId); + if (definition != null && definition.actorVisualBehavior().substitutesMaleRole()) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.male_roles").formatted(Formatting.GREEN)); + int capacity = definition.actorVisualBehavior().storedLiquidCapacityMl(); + if (capacity <= 0) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.no_injected_liquid").formatted(Formatting.GRAY)); + } else { + NonAccessoryStackData.StoredLiquid stored = NonAccessoryStackData.getStoredLiquid(stack); + if (stored == null) { + lines.add(Text.translatable("item.needsofnature.accessory.tooltip.stored_liquid.empty", new Object[]{capacity}).formatted(Formatting.GRAY)); + } else { + lines.add( + Text.translatable( + "item.needsofnature.accessory.tooltip.stored_liquid.filled", new Object[]{stored.amountMl(), stored.entityTypeId().toString()} + ) + .formatted(Formatting.AQUA) + ); + } + } + } + } + + private static boolean addLiquidDecayTooltip(List lines, Identifier itemId, double value, NonTrinketsIntegration.TankScopeStatus tankScope) { + if (isNeutral(value)) { + return false; + } + + boolean beneficial = value < 1.0; + String key = beneficial ? "item.needsofnature.accessory.tooltip.liquid_decay_reduced" : "item.needsofnature.accessory.tooltip.liquid_decay_increased"; + lines.add(tankScopedText(Text.translatable(key, new Object[]{signedPercentDelta(value)}), itemId, "liquid_decay_multiplier", beneficial, tankScope)); + return tankScope == NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED; + } + + private static void addMultiplierTooltip(List lines, Identifier itemId, String keyPart, String effectKey, double value, boolean higherIsBeneficial) { + if (!isNeutral(value)) { + boolean increased = value > 1.0; + boolean beneficial = increased == higherIsBeneficial; + String direction = increased ? "increased" : "reduced"; + lines.add( + styledText( + Text.translatable("item.needsofnature.accessory.tooltip." + keyPart + "." + direction, new Object[]{signedPercentDelta(value)}), + itemId, + effectKey, + beneficial + ) + ); + } + } + + private static boolean addMultiplierTooltip( + List lines, + Identifier itemId, + String keyPart, + String effectKey, + double value, + boolean higherIsBeneficial, + NonTrinketsIntegration.TankScopeStatus tankScope + ) { + if (isNeutral(value)) { + return false; + } + + boolean increased = value > 1.0; + boolean beneficial = increased == higherIsBeneficial; + String direction = increased ? "increased" : "reduced"; + lines.add( + tankScopedText( + Text.translatable("item.needsofnature.accessory.tooltip." + keyPart + "." + direction, new Object[]{signedPercentDelta(value)}), + itemId, + effectKey, + beneficial, + tankScope + ) + ); + return tankScope == NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED; + } + + private static void addAdditiveTooltip(List lines, Identifier itemId, String keyPart, String effectKey, int value, boolean higherIsBeneficial) { + if (value != 0) { + boolean increased = value > 0; + boolean beneficial = increased == higherIsBeneficial; + String direction = increased ? "increased" : "reduced"; + lines.add( + styledText( + Text.translatable("item.needsofnature.accessory.tooltip." + keyPart + "." + direction, new Object[]{signedInt(value)}), + itemId, + effectKey, + beneficial + ) + ); + } + } + + private static boolean addAdditiveTooltip( + List lines, + Identifier itemId, + String keyPart, + String effectKey, + int value, + boolean higherIsBeneficial, + NonTrinketsIntegration.TankScopeStatus tankScope + ) { + if (value == 0) { + return false; + } + + boolean increased = value > 0; + boolean beneficial = increased == higherIsBeneficial; + String direction = increased ? "increased" : "reduced"; + lines.add( + tankScopedText( + Text.translatable("item.needsofnature.accessory.tooltip." + keyPart + "." + direction, new Object[]{signedInt(value)}), + itemId, + effectKey, + beneficial, + tankScope + ) + ); + return tankScope == NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED; + } + + private static Text tankScopedText(Text body, Identifier itemId, String effectKey, boolean beneficial, NonTrinketsIntegration.TankScopeStatus tankScope) { + MutableText text = Text.literal("").append(TANK_SCOPED_ICON).append(body); + return (Text)(tankScope == NonTrinketsIntegration.TankScopeStatus.INACTIVE_EQUIPPED + ? text.formatted(Formatting.GRAY) + : applyColor(text, NonAccessoryDefinitions.getTooltipColor(itemId, effectKey), beneficial)); + } + + private static Text styledText(Text body, Identifier itemId, String effectKey, boolean beneficial) { + return applyColor(body.copy(), NonAccessoryDefinitions.getTooltipColor(itemId, effectKey), beneficial); + } + + private static Text applyColor(MutableText text, NonAccessoryDefinitions.TooltipColor color, boolean beneficial) { + if (color == null) { + return text.formatted(beneficial ? Formatting.GREEN : Formatting.RED); + } else if (color.formatting() != null) { + return text.formatted(color.formatting()); + } else { + return color.rgb() != null ? text.setStyle(text.getStyle().withColor(color.rgb())) : text.formatted(beneficial ? Formatting.GREEN : Formatting.RED); + } + } + + private static boolean isNeutral(double value) { + return Math.abs(value - 1.0) < 1.0E-6; + } + + private static String signedPercentDelta(double value) { + int percent = (int)Math.round(Math.abs(value - 1.0) * 100.0); + String sign = value >= 1.0 ? "+" : "-"; + return sign + percent + "%"; + } + + private static String signedInt(int value) { + return (value > 0 ? "+" : "") + value; + } + + private static NonTrinketsIntegration.TankScopeStatus resolveTankScopeStatus(ItemStack stack) { + MinecraftClient client = MinecraftClient.getInstance(); + ClientPlayerEntity player = client == null ? null : client.player; + return NonTrinketsIntegration.resolveTankScopeStatus(player, stack); + } +} diff --git a/src/main/java/com/nonid/client/NonAnimationPreviewRenderer.java b/src/main/java/com/nonid/client/NonAnimationPreviewRenderer.java new file mode 100644 index 0000000..1aed578 --- /dev/null +++ b/src/main/java/com/nonid/client/NonAnimationPreviewRenderer.java @@ -0,0 +1,94 @@ +package com.nonid.client; + +import com.mojang.blaze3d.textures.GpuTextureView; +import com.nonid.internal.animation.AnimationEngine; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; + +public final class NonAnimationPreviewRenderer { + private static final Map> TEXTURE_CACHE = new HashMap<>(); + + private NonAnimationPreviewRenderer() { + } + + public static void draw(DrawContext context, TextRenderer textRenderer, String animationId, int x, int y, int size) { + Optional preview = previewTexture(animationId); + if (preview.isPresent()) { + NonAnimationPreviewRenderer.PreviewTexture texture = preview.get(); + int textureWidth = texture.width(); + int textureHeight = texture.height(); + int drawWidth = size; + int drawHeight = size; + if (textureWidth > textureHeight) { + drawHeight = Math.max(1, Math.round(size * ((float)textureHeight / textureWidth))); + } else if (textureHeight > textureWidth) { + drawWidth = Math.max(1, Math.round(size * ((float)textureWidth / textureHeight))); + } + + int drawX = x + (size - drawWidth) / 2; + int drawY = y + (size - drawHeight) / 2; + context.fill(x, y, x + size, y + size, -13948117); + context.drawTexture( + RenderPipelines.GUI_TEXTURED, + texture.id(), + drawX, + drawY, + 0.0F, + 0.0F, + drawWidth, + drawHeight, + textureWidth, + textureHeight, + textureWidth, + textureHeight + ); + } else { + context.fill(x, y, x + size, y + size, -13948117); + context.drawCenteredTextWithShadow(textRenderer, "?", x + size / 2, y + size / 2 - 4, -5592406); + } + } + + public static void clearCache() { + TEXTURE_CACHE.clear(); + } + + private static Optional previewTexture(String animationId) { + return TEXTURE_CACHE.computeIfAbsent(animationId, NonAnimationPreviewRenderer::resolvePreviewTexture); + } + + private static Optional resolvePreviewTexture(String animationId) { + Identifier id = Identifier.tryParse(animationId); + if (id == null) { + return Optional.empty(); + } + + String path = "textures/afw/previews/" + id.getPath() + ".png"; + Identifier namespaced = Identifier.of(id.getNamespace(), path); + MinecraftClient client = MinecraftClient.getInstance(); + Identifier preview = null; + if (client.getResourceManager().getResource(namespaced).isPresent()) { + preview = namespaced; + } else { + Identifier fallback = AnimationEngine.packId(path); + if (client.getResourceManager().getResource(fallback).isPresent()) { + preview = fallback; + } + } + + if (preview == null) { + return Optional.empty(); + } + + GpuTextureView textureView = client.getTextureManager().getTexture(preview).getGlTextureView(); + return Optional.of(new NonAnimationPreviewRenderer.PreviewTexture(preview, Math.max(1, textureView.getWidth(0)), Math.max(1, textureView.getHeight(0)))); + } + + private record PreviewTexture(Identifier id, int width, int height) { + } +} diff --git a/src/main/java/com/nonid/client/NonArmorShedClient.java b/src/main/java/com/nonid/client/NonArmorShedClient.java new file mode 100644 index 0000000..713647e --- /dev/null +++ b/src/main/java/com/nonid/client/NonArmorShedClient.java @@ -0,0 +1,307 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.NonAccessoryShedSystem; +import com.nonid.NonTrinketsIntegration; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderContext; +import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderEvents; +import net.minecraft.block.BlockState; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.render.entity.ItemEntityRenderer; +import net.minecraft.client.render.entity.state.ItemStackEntityRenderState; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.EquipmentSlot; +import net.minecraft.entity.ItemEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.RotationAxis; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.math.Direction.Axis; +import net.minecraft.util.shape.VoxelShape; + +public final class NonArmorShedClient { + private static final EquipmentSlot[] ARMOR_SLOTS = new EquipmentSlot[]{EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET}; + private static final int FLYING_TICKS = 24; + private static final int GROUND_TICKS = 40; + private static final int GROUND_FADE_TICKS = 30; + private static final double GRAVITY = 0.035; + private static final double AIR_DRAG = 0.96; + private static final double GROUND_DRAG = 0.78; + private static final float ITEM_SCALE = 1.0F; + private static final long FRESH_START_MAX_AGE_TICKS = 5L; + private static final long QUEUED_HANDOFF_SKIP_TICKS = 12L; + private static final Identifier PLAYER_DEFEATED = Identifier.of("needsofnature", "player_defeated-belly"); + private static final Identifier PLAYER_BIRTH = Identifier.of("needsofnature", "player_birth"); + private static final List PROPS = new ArrayList<>(); + private static final Map LAST_SPAWNED_START_TICK = new HashMap<>(); + private static final Map LAST_ACTIVE_TICK = new HashMap<>(); + + private NonArmorShedClient() { + } + + public static void register() { + WorldRenderEvents.AFTER_ENTITIES.register(NonArmorShedClient::render); + } + + public static void clientTick(MinecraftClient client) { + if (!NeedsOfNature.getConfig().isArmorShedEffectEnabled()) { + clear(); + } else { + tickProps(client); + if (client != null && client.world != null) { + long now = client.world.getTime(); + + for (AbstractClientPlayerEntity player : client.world.getPlayers()) { + if (player != null && !player.isRemoved()) { + UUID uuid = player.getUuid(); + if (!ClientAnimationRuntime.isActorActive(uuid)) { + LAST_SPAWNED_START_TICK.remove(uuid); + } else { + Long previousActiveTick = LAST_ACTIVE_TICK.get(uuid); + Long startTick = ClientAnimationRuntime.findStartTickForActor(uuid); + if (startTick == null) { + LAST_ACTIVE_TICK.put(uuid, now); + } else { + Long lastSpawned = LAST_SPAWNED_START_TICK.get(uuid); + if (lastSpawned != null && lastSpawned.equals(startTick)) { + LAST_ACTIVE_TICK.put(uuid, now); + } else { + if (shouldSpawnForStart(uuid, now, startTick, previousActiveTick)) { + spawnForPlayer(player, startTick); + } + + LAST_SPAWNED_START_TICK.put(uuid, startTick); + LAST_ACTIVE_TICK.put(uuid, now); + } + } + } + } + } + } else { + clear(); + } + } + } + + public static void clear() { + PROPS.clear(); + LAST_SPAWNED_START_TICK.clear(); + LAST_ACTIVE_TICK.clear(); + } + + private static boolean shouldSpawnForStart(UUID playerUuid, long now, long startTick, Long previousActiveTick) { + if (now - startTick > 5L) { + return false; + } + + if (previousActiveTick != null && now - previousActiveTick <= 12L) { + return false; + } + + Identifier activeAnimation = ClientAnimationRuntime.findLatestActiveAnimationIdContaining(playerUuid); + return !isQueuedFollowUpAnimation(activeAnimation); + } + + private static boolean isQueuedFollowUpAnimation(Identifier animationId) { + return animationId == null ? false : PLAYER_DEFEATED.equals(animationId) || PLAYER_BIRTH.equals(animationId); + } + + private static void spawnForPlayer(AbstractClientPlayerEntity player, long startTick) { + Vec3d origin = new Vec3d(player.getX(), player.getY() + player.getHeight() * 0.72, player.getZ()); + long seedBase = player.getUuid().getMostSignificantBits() ^ player.getUuid().getLeastSignificantBits() ^ startTick; + Random random = new Random(seedBase); + float yawRad = player.getYaw() * (float) (Math.PI / 180.0); + Vec3d forward = new Vec3d(-MathHelper.sin(yawRad), 0.0, MathHelper.cos(yawRad)); + Vec3d right = new Vec3d(forward.z, 0.0, -forward.x); + + for (int i = 0; i < ARMOR_SLOTS.length; i++) { + EquipmentSlot slot = ARMOR_SLOTS[i]; + ItemStack stack = player.getEquippedStack(slot); + if (stack != null && !stack.isEmpty()) { + double side = (i - 1.5) * 0.18; + Vec3d spawnPos = origin.add(right.multiply(side)).add(0.0, -0.08 * i, 0.0); + addProp(stack, spawnPos, forward, right, random, seedBase, i, ARMOR_SLOTS.length); + } + } + + spawnShedAccessories(player, origin, forward, right, random, seedBase); + } + + private static void spawnShedAccessories(AbstractClientPlayerEntity player, Vec3d origin, Vec3d forward, Vec3d right, Random random, long seedBase) { + NonAccessoryShedSystem.ShedState shedState = NonAccessoryShedSystem.getShedState(player.getUuid()); + if (!shedState.isEmpty()) { + List stacks = NonTrinketsIntegration.getVisuallyShedAccessoryStacks(player); + + for (int index = 0; index < stacks.size(); index++) { + ItemStack stack = stacks.get(index); + if (stack != null && !stack.isEmpty()) { + double side = (index - 0.5) * 0.24; + Vec3d spawnPos = origin.add(right.multiply(side)).add(0.0, -0.34, 0.0); + addProp(stack, spawnPos, forward, right, random, seedBase, 20 + index, 2); + } + } + } + } + + private static void addProp(ItemStack stack, Vec3d spawnPos, Vec3d forward, Vec3d right, Random random, long seedBase, int index, int count) { + ItemStack visualStack = stack.copy(); + visualStack.setCount(1); + double angle = (Math.PI * 2) * index / Math.max(1, count) + (random.nextDouble() - 0.5) * 0.55; + Vec3d outward = right.multiply(Math.cos(angle)).add(forward.multiply(Math.sin(angle))).normalize(); + double speed = 0.105 + random.nextDouble() * 0.035; + Vec3d velocity = outward.multiply(speed).add(0.0, 0.15 + random.nextDouble() * 0.13, 0.0); + PROPS.add( + new NonArmorShedClient.ArmorProp( + visualStack, + spawnPos, + velocity, + random.nextFloat() * 360.0F, + random.nextFloat() * 360.0F, + 7.0F + random.nextFloat() * 7.0F, + 10.0F + random.nextFloat() * 12.0F, + (int)(seedBase + index * 31L) + ) + ); + } + + private static void tickProps(MinecraftClient client) { + if (!PROPS.isEmpty()) { + if (client != null && client.world != null) { + Iterator iterator = PROPS.iterator(); + + while (iterator.hasNext()) { + NonArmorShedClient.ArmorProp prop = iterator.next(); + prop.age++; + prop.previousPosition = prop.position; + if (prop.landed) { + prop.groundAge++; + prop.velocity = prop.velocity.multiply(0.78, 0.0, 0.78); + if (prop.groundAge > 40) { + iterator.remove(); + } + } else { + prop.velocity = prop.velocity.add(0.0, -0.035, 0.0).multiply(0.96, 0.98, 0.96); + Vec3d next = prop.position.add(prop.velocity); + NonArmorShedClient.GroundHit hit = findGround(client, next); + if (hit.hit() && prop.velocity.y <= 0.0 && prop.age > 3) { + prop.position = new Vec3d(next.x, hit.y(), next.z); + prop.velocity = Vec3d.ZERO; + prop.landed = true; + } else { + prop.position = next; + if (prop.age > 64) { + iterator.remove(); + } + } + } + } + } else { + clear(); + } + } + } + + private static NonArmorShedClient.GroundHit findGround(MinecraftClient client, Vec3d pos) { + if (client != null && client.world != null) { + BlockPos blockPos = BlockPos.ofFloored(pos.x, pos.y - 0.04, pos.z); + BlockState state = client.world.getBlockState(blockPos); + VoxelShape shape = state.getCollisionShape(client.world, blockPos); + if (shape.isEmpty()) { + return NonArmorShedClient.GroundHit.MISS; + } + + double top = blockPos.getY() + shape.getMax(Axis.Y); + return pos.y < top + 0.12 ? new NonArmorShedClient.GroundHit(true, top + 0.03) : NonArmorShedClient.GroundHit.MISS; + } else { + return NonArmorShedClient.GroundHit.MISS; + } + } + + private static void render(WorldRenderContext context) { + if (NeedsOfNature.getConfig().isArmorShedEffectEnabled()) { + if (!PROPS.isEmpty()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null) { + Vec3d cameraPos = context.worldState().cameraRenderState.pos; + if (cameraPos != null) { + float tickProgress = client.getRenderTickCounter().getTickProgress(false); + MatrixStack matrices = context.matrices(); + + for (NonArmorShedClient.ArmorProp prop : PROPS) { + Vec3d renderPos = prop.previousPosition.lerp(prop.position, tickProgress); + matrices.push(); + matrices.translate(renderPos.x - cameraPos.x, renderPos.y - cameraPos.y, renderPos.z - cameraPos.z); + float smoothAge = prop.age + tickProgress; + float yaw = prop.landed ? prop.groundYaw : prop.baseYaw + smoothAge * prop.yawSpeed; + float pitch = prop.landed ? 90.0F : prop.basePitch + smoothAge * prop.pitchSpeed; + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(yaw)); + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(pitch)); + float fadeScale = prop.landed ? prop.groundFadeScale(tickProgress) : 1.0F; + matrices.scale(1.0F * fadeScale, 1.0F * fadeScale, 1.0F * fadeScale); + ItemStackEntityRenderState state = new ItemStackEntityRenderState(); + ItemEntity fake = new ItemEntity(client.world, prop.position.x, prop.position.y, prop.position.z, prop.stack); + state.update(fake, prop.stack, client.getItemModelManager()); + ItemEntityRenderer.renderStack(matrices, context.commandQueue(), 15728880, state, net.minecraft.util.math.random.Random.create(prop.seed)); + matrices.pop(); + } + } + } + } + } + } + + private static final class ArmorProp { + final ItemStack stack; + Vec3d position; + Vec3d previousPosition; + Vec3d velocity; + final float baseYaw; + final float basePitch; + final float yawSpeed; + final float pitchSpeed; + final float groundYaw; + final int seed; + int age; + int groundAge; + boolean landed; + + ArmorProp(ItemStack stack, Vec3d position, Vec3d velocity, float baseYaw, float basePitch, float yawSpeed, float pitchSpeed, int seed) { + this.stack = stack; + this.position = position; + this.previousPosition = position; + this.velocity = velocity; + this.baseYaw = baseYaw; + this.basePitch = basePitch; + this.yawSpeed = yawSpeed; + this.pitchSpeed = pitchSpeed; + this.groundYaw = baseYaw; + this.seed = seed; + } + + float groundFadeScale(float tickProgress) { + int fadeStart = Math.max(0, 10); + float smoothGroundAge = this.groundAge + tickProgress; + if (smoothGroundAge <= fadeStart) { + return 1.0F; + } + + float remaining = (40.0F - smoothGroundAge) / 30.0F; + return MathHelper.clamp(remaining, 0.0F, 1.0F); + } + } + + private record GroundHit(boolean hit, double y) { + private static final NonArmorShedClient.GroundHit MISS = new NonArmorShedClient.GroundHit(false, 0.0); + } +} diff --git a/src/main/java/com/nonid/client/NonClientDiagnostics.java b/src/main/java/com/nonid/client/NonClientDiagnostics.java new file mode 100644 index 0000000..23bfbae --- /dev/null +++ b/src/main/java/com/nonid/client/NonClientDiagnostics.java @@ -0,0 +1,32 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.NonDebugChatCategory; +import com.nonid.NonDiagnostics; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +public final class NonClientDiagnostics { + private NonClientDiagnostics() { + } + + public static boolean shouldSend(NonDebugChatCategory category) { + return NeedsOfNature.getConfig().allowsDebugChat(category); + } + + public static void send(NonDebugChatCategory category, Text message, boolean withPrefix) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && message != null) { + if (shouldSend(category)) { + Text output = (Text)(withPrefix ? Text.translatable("debug.needsofnature.prefix", new Object[]{message}) : message); + client.player.sendMessage(output.copy().formatted(NonDiagnostics.chatColor(category)), false); + } + } + } + + public static void sendSetupWarning(String message, boolean withPrefix) { + if (message != null && !message.isBlank()) { + send(NonDebugChatCategory.SETUP, Text.literal(message), withPrefix); + } + } +} diff --git a/src/main/java/com/nonid/client/NonClientResourceCaches.java b/src/main/java/com/nonid/client/NonClientResourceCaches.java new file mode 100644 index 0000000..8abeeb4 --- /dev/null +++ b/src/main/java/com/nonid/client/NonClientResourceCaches.java @@ -0,0 +1,30 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNatureClient; +import com.nonid.internal.animation.client.render.gecko.AnimationActorAnimatable; +import com.nonid.internal.animation.client.render.gecko.GeckoResourceResolver; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; + +public final class NonClientResourceCaches { + private static final Identifier RELOAD_LISTENER_ID = Identifier.of("needsofnature", "client_resource_caches"); + + private NonClientResourceCaches() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES).registerReloader(RELOAD_LISTENER_ID, (SynchronousResourceReloader)manager -> clear()); + } + + private static void clear() { + NeedsOfNatureClient.clearGeneratedTintTextures(); + NonDestroyedSkinClient.clearResourceCaches(); + NonAnimationPreviewRenderer.clearCache(); + PregnancyEggGeoModel.clearTextureCache(); + NonStatLabelResolver.clearCache(); + AnimationActorAnimatable.clearResourceCaches(); + GeckoResourceResolver.clearResourceCaches(); + } +} diff --git a/src/main/java/com/nonid/client/NonDebugNameHelper.java b/src/main/java/com/nonid/client/NonDebugNameHelper.java new file mode 100644 index 0000000..3801f34 --- /dev/null +++ b/src/main/java/com/nonid/client/NonDebugNameHelper.java @@ -0,0 +1,83 @@ +package com.nonid.client; + +import com.nonid.EnergyHolder; +import com.nonid.GenderHolder; +import com.nonid.NeedsOfNature; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.Entity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; + +public final class NonDebugNameHelper { + private NonDebugNameHelper() { + } + + public static boolean shouldShowDebugLabel(Entity entity) { + if (!(entity instanceof MobEntity)) { + return false; + } + + if (!(entity instanceof EnergyHolder)) { + return false; + } + + MinecraftClient client = MinecraftClient.getInstance(); + PlayerEntity player = client.player; + if (player == null) { + return false; + } + + ItemStack main = player.getMainHandStack(); + ItemStack off = player.getOffHandStack(); + return main.getItem() == NeedsOfNature.DEBUG_STAFF || off.getItem() == NeedsOfNature.DEBUG_STAFF; + } + + public static String resolveGender(Entity entity) { + if (entity instanceof GenderHolder holder) { + boolean male = holder.hasGender(1); + boolean female = holder.hasGender(2); + if (male && female) { + return "male & female"; + } else if (male) { + return "male"; + } else { + return female ? "female" : "none"; + } + } else { + boolean hasMale = entity.getCommandTags().contains("gender.male"); + boolean hasFemale = entity.getCommandTags().contains("gender.female"); + if (hasMale && hasFemale) { + return "male & female"; + } else if (hasMale) { + return "male"; + } else { + return hasFemale ? "female" : "none"; + } + } + } + + public static String resolveGenderSymbol(Entity entity) { + if (entity instanceof GenderHolder holder) { + boolean male = holder.hasGender(1); + boolean female = holder.hasGender(2); + if (male && female) { + return "♂♀"; + } else if (male) { + return "♂"; + } else { + return female ? "♀" : ""; + } + } else { + boolean hasMale = entity.getCommandTags().contains("gender.male"); + boolean hasFemale = entity.getCommandTags().contains("gender.female"); + if (hasMale && hasFemale) { + return "♂♀"; + } else if (hasMale) { + return "♂"; + } else { + return hasFemale ? "♀" : ""; + } + } + } +} diff --git a/src/main/java/com/nonid/client/NonDebugSpinMode.java b/src/main/java/com/nonid/client/NonDebugSpinMode.java new file mode 100644 index 0000000..1c871f6 --- /dev/null +++ b/src/main/java/com/nonid/client/NonDebugSpinMode.java @@ -0,0 +1,19 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import net.minecraft.util.Identifier; + +public final class NonDebugSpinMode { + public static final Identifier DEBUG_SPIN_ANIMATION_RESOURCE = Identifier.of("needsofnature", "afw/debug_spin/debug_spin"); + public static final Identifier DEBUG_SPIN_MODEL_RESOURCE = Identifier.of("needsofnature", "fish"); + public static final Identifier DEBUG_SPIN_TEXTURE_RESOURCE = Identifier.ofVanilla("textures/entity/fish/salmon.png"); + + private NonDebugSpinMode() { + } + + public static boolean isEnabled() { + NonConfig config = NeedsOfNature.getConfig(); + return config != null && config.isDebugSpinMode(); + } +} diff --git a/src/main/java/com/nonid/client/NonDebugSpinMusicClient.java b/src/main/java/com/nonid/client/NonDebugSpinMusicClient.java new file mode 100644 index 0000000..519c6b9 --- /dev/null +++ b/src/main/java/com/nonid/client/NonDebugSpinMusicClient.java @@ -0,0 +1,171 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.sound.MovingSoundInstance; +import net.minecraft.client.sound.SoundInstance; +import net.minecraft.client.sound.SoundInstance.AttenuationType; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.registry.Registries; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; + +public final class NonDebugSpinMusicClient { + private static final Identifier SOUND_ID = NeedsOfNature.id("funkytown"); + private static final float VOLUME = 0.025F; + private static final float PITCH = 1.0F; + private static final Map ACTIVE = new HashMap<>(); + + private NonDebugSpinMusicClient() { + } + + public static void clientTick(MinecraftClient client) { + if (client != null) { + if (NonDebugSpinMode.isEnabled() && client.world != null) { + pruneStoppedSounds(client); + Map anchors = findActiveInstanceAnchors(client.world); + stopInactiveSounds(client, anchors.keySet()); + + for (Entry entry : anchors.entrySet()) { + playForInstance(client, entry.getKey(), entry.getValue()); + } + } else { + stopAll(client); + } + } + } + + public static void stopAll(MinecraftClient client) { + if (!ACTIVE.isEmpty()) { + for (NonDebugSpinMusicClient.SpinMusicSound sound : ACTIVE.values()) { + sound.finish(); + if (client != null) { + client.getSoundManager().stop(sound); + } + } + + ACTIVE.clear(); + } + } + + private static Map findActiveInstanceAnchors(ClientWorld world) { + if (world == null) { + return Map.of(); + } + + Map anchors = new HashMap<>(); + + for (Entity entity : world.getEntities()) { + if (entity instanceof LivingEntity living) { + UUID actorUuid = living.getUuid(); + UUID instanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(actorUuid); + if (instanceId != null && !anchors.containsKey(instanceId)) { + UUID anchorUuid = ClientAnimationRuntime.findAnchorUuidForActor(actorUuid); + anchors.put(instanceId, anchorUuid != null ? anchorUuid : actorUuid); + } + } + } + + return anchors.isEmpty() ? Map.of() : Map.copyOf(anchors); + } + + private static void stopInactiveSounds(MinecraftClient client, Set activeInstances) { + Iterator> iterator = ACTIVE.entrySet().iterator(); + + while (iterator.hasNext()) { + Entry entry = iterator.next(); + if (!activeInstances.contains(entry.getKey())) { + NonDebugSpinMusicClient.SpinMusicSound sound = entry.getValue(); + if (sound != null) { + sound.finish(); + client.getSoundManager().stop(sound); + } + + iterator.remove(); + } + } + } + + private static void pruneStoppedSounds(MinecraftClient client) { + Iterator> iterator = ACTIVE.entrySet().iterator(); + + while (iterator.hasNext()) { + NonDebugSpinMusicClient.SpinMusicSound sound = iterator.next().getValue(); + if (sound == null || sound.isDone() || client != null && !client.getSoundManager().isPlaying(sound)) { + iterator.remove(); + } + } + } + + private static void playForInstance(MinecraftClient client, UUID instanceId, UUID anchorUuid) { + if (client != null && client.world != null && instanceId != null && anchorUuid != null) { + NonDebugSpinMusicClient.SpinMusicSound existing = ACTIVE.get(instanceId); + if (existing == null || existing.isDone()) { + Entity anchor = client.world.getEntity(anchorUuid); + if (anchor != null) { + NonDebugSpinMusicClient.SpinMusicSound sound = new NonDebugSpinMusicClient.SpinMusicSound( + instanceId, anchorUuid, anchor instanceof PlayerEntity ? SoundCategory.PLAYERS : SoundCategory.NEUTRAL, 0.025F, 1.0F + ); + ACTIVE.put(instanceId, sound); + client.getSoundManager().play(sound); + } + } + } + } + + private static final class SpinMusicSound extends MovingSoundInstance { + private final UUID instanceId; + private final UUID anchorUuid; + + private SpinMusicSound(UUID instanceId, UUID anchorUuid, SoundCategory category, float volume, float pitch) { + super((SoundEvent)Registries.SOUND_EVENT.get(NonDebugSpinMusicClient.SOUND_ID), category, SoundInstance.createRandom()); + this.instanceId = instanceId; + this.anchorUuid = anchorUuid; + this.volume = volume; + this.pitch = pitch; + this.repeat = true; + this.repeatDelay = 0; + this.relative = false; + this.attenuationType = AttenuationType.LINEAR; + this.updatePosition(); + } + + public void tick() { + if (!NonDebugSpinMode.isEnabled() || ClientAnimationRuntime.findCurrentStage(this.instanceId) == null || !this.updatePosition()) { + this.setDone(); + } + } + + private boolean updatePosition() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null) { + Entity anchor = client.world.getEntity(this.anchorUuid); + if (anchor == null) { + return false; + } + + this.x = anchor.getX(); + this.y = anchor.getY(); + this.z = anchor.getZ(); + return true; + } else { + return false; + } + } + + private void finish() { + this.setDone(); + } + } +} diff --git a/src/main/java/com/nonid/client/NonDestroyedSkinClient.java b/src/main/java/com/nonid/client/NonDestroyedSkinClient.java new file mode 100644 index 0000000..709d5c7 --- /dev/null +++ b/src/main/java/com/nonid/client/NonDestroyedSkinClient.java @@ -0,0 +1,2343 @@ +package com.nonid.client; + +import com.nonid.DestroyedSkinHolder; +import com.nonid.GenderHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NeedsOfNatureClient; +import com.nonid.NonConfig; +import com.nonid.NonTrinketsIntegration; +import com.nonid.client.particle.RippedFabricParticle; +import com.nonid.network.DestroyedSkinMaskUploadC2SPayload; +import com.nonid.network.DestroyedSkinUploadC2SPayload; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.client.texture.NativeImage.Format; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerSkinType; +import net.minecraft.resource.Resource; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.random.Random; +import org.jetbrains.annotations.Nullable; + +public final class NonDestroyedSkinClient { + private static final int MIN_STAGE = 0; + private static final int MAX_STAGE = 4; + private static final int EXTERNAL_SKIN_SELECTION_POLL_TICKS = 20; + private static final long TEXTURE_READ_RETRY_NANOS = 1000000000L; + private static final int FALLBACK_FABRIC_RGB = 15920872; + private static final int FALLBACK_SKIN_TINT_RGB = 13803641; + private static final Identifier RIPPED_FABRIC_COLOR_MASK = Identifier.of("needsofnature", "textures/particle/ripped_fabric_color_mask.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_F = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_f.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_M = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_m.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_F_SLIM = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_f_slim.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_F_WIDE = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_f_wide.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_M_SLIM = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_m_slim.png"); + private static final Identifier DEFAULT_DESTROYED_TEXTURE_M_WIDE = Identifier.of("needsofnature", "textures/player_destroyed/destroyed_texture_m_wide.png"); + private static final String DEFAULT_PLAYER_SKIN_PREFIX = "textures/entity/player/"; + private static final String DEFAULT_DESTROYED_SKIN_PREFIX = "textures/player_destroyed/default/"; + private static final String[] VANILLA_DEFAULT_SKIN_NAMES = new String[]{"steve", "alex", "ari", "efe", "kai", "makena", "noor", "sunny", "zuri"}; + private static final double DEFAULT_SKIN_PIXEL_MATCH_THRESHOLD = 0.9; + private static final Identifier[] MASK_TEXTURES = new Identifier[]{ + null, + Identifier.of("needsofnature", "textures/player_destroyed/mask_1.png"), + Identifier.of("needsofnature", "textures/player_destroyed/mask_2.png"), + Identifier.of("needsofnature", "textures/player_destroyed/mask_3.png") + }; + private static final Identifier[][] MESS_MASK_TEXTURES = new Identifier[][]{ + { + Identifier.of("needsofnature", "textures/entity/player/mess/v_mess1.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/v_mess2.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/v_mess3.png") + }, + { + Identifier.of("needsofnature", "textures/entity/player/mess/a_mess1.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/a_mess2.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/a_mess3.png") + }, + { + Identifier.of("needsofnature", "textures/entity/player/mess/m_mess1.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/m_mess2.png"), + Identifier.of("needsofnature", "textures/entity/player/mess/m_mess3.png") + } + }; + private static final Identifier V_MESS_TANK_TEXTURE = Identifier.of("needsofnature", "textures/entity/player/mess/v_mess_tank.png"); + private static final Identifier A_MESS_TANK_TEXTURE = Identifier.of("needsofnature", "textures/entity/player/mess/a_mess_tank.png"); + private static final Map DESTROYED_SKINS = new HashMap<>(); + private static final Map DESTROYED_SKIN_MASKS = new HashMap<>(); + private static final Map STAGES = new HashMap<>(); + private static final Map MESS_STATES = new HashMap<>(); + private static final Map TEXTURE_CACHE = new HashMap<>(); + private static final Map ORIGINAL_SKIN_COLOR_CACHE = new HashMap<>(); + private static final Map EFFECTIVE_BASE_TEXTURES = new HashMap<>(); + private static final Map OBSERVED_EXTERNAL_SKINS = new HashMap<>(); + private static final Map FAILED_TEXTURE_READS = new HashMap<>(); + private static final Set WARNED_TEXTURE_READS = new HashSet<>(); + private static int uploadRetryTicks = 0; + private static boolean uploadedThisConnection = false; + private static boolean uploadedMasksForSelection = false; + private static boolean automaticSkinUnavailableThisConnection = false; + @Nullable + private static String lastUploadedAutoBaseSkinKey = null; + private static int externalSkinSelectionPollTicks = 0; + private static int skinOverrideCacheVersion = Integer.MIN_VALUE; + + private NonDestroyedSkinClient() { + } + + public static void ensureConfigDirectory() { + Path directory = configRoot(); + + try { + Files.createDirectories(directory); + } catch (IOException e) { + setupWarn("[NoN] Failed to create ripped-skin config directory {}.", directory, e); + } + } + + public static void scheduleLocalSkinUploadRetry() { + uploadRetryTicks = 300; + uploadedThisConnection = false; + uploadedMasksForSelection = false; + automaticSkinUnavailableThisConnection = false; + lastUploadedAutoBaseSkinKey = null; + } + + public static void clientTick(MinecraftClient client) { + if (client != null) { + boolean skinOverrideChanged = refreshSkinOverrideCacheVersion(); + pollExternalSkinSelections(client, skinOverrideChanged); + if (uploadRetryTicks > 0) { + uploadRetryTicks--; + if (!ClientPlayNetworking.canSend(DestroyedSkinUploadC2SPayload.ID)) { + if (uploadRetryTicks == 0) { + NeedsOfNature.LOGGER.info("[NoN] Destroyed skin upload retry expired: upload channel never became sendable."); + } + } else { + boolean localCustomSkin = hasLocalCustomDestroyedSkin(); + if (localCustomSkin && uploadedThisConnection && uploadedMasksForSelection) { + uploadRetryTicks = 0; + } else if (!localCustomSkin && automaticSkinUnavailableThisConnection) { + uploadedMasksForSelection = uploadedMasksForSelection | uploadLocalMasks(); + if (uploadedMasksForSelection) { + uploadRetryTicks = 0; + } + } else { + boolean uploadedSkin = uploadedThisConnection || uploadLocalSkinIfPresent(); + if (!uploadedMasksForSelection) { + uploadedMasksForSelection = uploadLocalMasks(); + } + + uploadedThisConnection |= uploadedSkin; + if (localCustomSkin && uploadedThisConnection && uploadedMasksForSelection) { + uploadRetryTicks = 0; + } else if (uploadRetryTicks == 0 && !uploadedThisConnection && !automaticSkinUnavailableThisConnection) { + NeedsOfNature.LOGGER.info("[NoN] Destroyed skin upload retry expired without upload."); + } + } + } + } + } + } + + public static boolean uploadLocalSkinIfPresent() { + for (Path path : localOverrideCandidates("destroyed_skin.png")) { + if (Files.isRegularFile(path)) { + try { + byte[] bytes = Files.readAllBytes(path); + if (!isValidDestroyedSkinPng(bytes)) { + setupWarn("[NoN] Ignoring destroyed skin {}: expected a square PNG from 64x64 through 512x512 in 64-pixel steps.", path); + } else if (ClientPlayNetworking.canSend(DestroyedSkinUploadC2SPayload.ID)) { + ClientPlayNetworking.send(new DestroyedSkinUploadC2SPayload(bytes)); + return true; + } + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to upload local destroyed skin from {}.", path, e); + } + } + } + + return uploadGeneratedSkinIfPossible(); + } + + public static boolean hasLocalCustomDestroyedSkin() { + return firstExistingLocalOverride("destroyed_skin.png") != null; + } + + public static boolean hasCurrentDefaultDestroyedSkin() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + Identifier baseSkin = client.player.getSkin().body().texturePath(); + if (baseSkin == null) { + return false; + } + + boolean defaultSkinPath = isLikelyDefaultPlayerSkin(baseSkin); + boolean profileHasCustomTexture = hasProfileTextureProperties(client); + if (defaultSkinPath && profileHasCustomTexture) { + return false; + } + + NonDestroyedSkinClient.DefaultSkinKey key = resolveDefaultSkinKey(client, baseSkin); + return key == null ? false : readDefaultDestroyedSkin(client, key) != null; + } else { + return false; + } + } + + private static Path localDestroyedSkinPath() { + List candidates = localOverrideCandidates("destroyed_skin.png"); + Path existing = firstExisting(candidates); + return existing != null ? existing : candidates.getFirst(); + } + + private static List localOverrideCandidates(String fileName) { + Path configRoot = configRoot(); + List candidates = new ArrayList<>(2); + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + UUID playerUuid = client.player.getUuid(); + NonDestroyedSkinClient.EffectiveBaseTexture effective = EFFECTIVE_BASE_TEXTURES.get(playerUuid); + Identifier textureId = effective != null ? effective.id() : client.player.getSkin().body().texturePath(); + NonExternalSkinCompat.SkinContext selection = effective != null ? effective.externalSkin() : null; + if (selection == null) { + selection = NonExternalSkinCompat.resolve(client, playerUuid, textureId); + } + + if (selection == null) { + selection = NonExternalSkinCompat.resolveSelected(client, playerUuid); + } + + if (selection != null && selection.folderName() != null) { + candidates.add(configRoot.resolve(selection.folderName()).resolve(fileName)); + } + } + + candidates.add(configRoot.resolve(fileName)); + return candidates; + } + + private static Path configRoot() { + return FabricLoader.getInstance().getConfigDir().resolve("needsofnature"); + } + + @Nullable + private static Path firstExistingLocalOverride(String fileName) { + return firstExisting(localOverrideCandidates(fileName)); + } + + @Nullable + private static Path firstExisting(List candidates) { + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + + return null; + } + + private static void pollExternalSkinSelections(MinecraftClient client, boolean force) { + if ((force || externalSkinSelectionPollTicks-- <= 0) && client.world != null) { + externalSkinSelectionPollTicks = 20; + Set presentPlayers = new HashSet<>(); + + for (AbstractClientPlayerEntity player : client.world.getPlayers()) { + UUID playerUuid = player.getUuid(); + presentPlayers.add(playerUuid); + Identifier textureId = player.getSkin().body().texturePath(); + NonExternalSkinCompat.SkinContext selection = NonExternalSkinCompat.resolveSelected(client, playerUuid); + String currentKey = observedSkinKey(textureId, selection); + String previousKey = OBSERVED_EXTERNAL_SKINS.put(playerUuid, currentKey); + if (previousKey != null && !Objects.equals(previousKey, currentKey)) { + NonDestroyedSkinClient.EffectiveBaseTexture previousEffective = EFFECTIVE_BASE_TEXTURES.remove(playerUuid); + if (previousEffective != null) { + removeOriginalSkinColors(previousEffective.id()); + clearTextureReadFailures(previousEffective.id()); + } + + removeOriginalSkinColors(textureId); + clearCachedTextures(playerUuid); + clearTextureReadFailures(textureId); + if (client.player != null && playerUuid.equals(client.player.getUuid())) { + if (selection != null) { + NeedsOfNature.LOGGER + .info( + "[NoN] {} skin selection changed to '{}'{}.", + new Object[]{ + selection.providerId(), + selection.displayName(), + selection.folderName() != null ? " (config folder '" + selection.folderName() + "')" : "" + } + ); + } else { + NeedsOfNature.LOGGER.info("[NoN] External skin override cleared; using the global ripped-skin configuration."); + } + + scheduleLocalSkinUploadRetry(); + } + } + } + + OBSERVED_EXTERNAL_SKINS.keySet().removeIf(uuid -> !presentPlayers.contains(uuid)); + } + } + + private static boolean refreshSkinOverrideCacheVersion() { + int currentVersion = NonSkinOverrideCompat.cacheVersion(); + if (currentVersion < 0) { + return false; + } + + if (skinOverrideCacheVersion == Integer.MIN_VALUE) { + skinOverrideCacheVersion = currentVersion; + return false; + } + + if (skinOverrideCacheVersion == currentVersion) { + return false; + } + + skinOverrideCacheVersion = currentVersion; + return true; + } + + private static String observedSkinKey(Identifier textureId, @Nullable NonExternalSkinCompat.SkinContext selection) { + return selection != null ? selection.selectionKey() : "minecraft\u0000" + textureId; + } + + private static boolean uploadGeneratedSkinIfPossible() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.player == null) { + return false; + } + + if (!ClientPlayNetworking.canSend(DestroyedSkinUploadC2SPayload.ID)) { + return false; + } + + if (automaticSkinUnavailableThisConnection) { + return false; + } + + Identifier baseSkin = client.player.getSkin().body().texturePath(); + if (baseSkin == null) { + return false; + } + + NonExternalSkinCompat.SkinContext externalSkin = NonExternalSkinCompat.resolve(client, client.player.getUuid(), baseSkin); + String uploadKey = baseSkin + "\u0000" + textureIdentity(client, baseSkin) + "\u0000" + externalSkinKey(externalSkin); + if (uploadKey.equals(lastUploadedAutoBaseSkinKey)) { + return true; + } + + boolean defaultSkinPath = isLikelyDefaultPlayerSkin(baseSkin); + boolean profileHasCustomTexture = hasProfileTextureProperties(client); + if (defaultSkinPath && profileHasCustomTexture) { + return false; + } + + NonDestroyedSkinClient.DefaultSkinKey defaultSkinKey = resolveDefaultSkinKey(client, baseSkin); + if (defaultSkinKey != null) { + byte[] defaultDestroyedSkin = readDefaultDestroyedSkin(client, defaultSkinKey); + if (defaultDestroyedSkin != null && isValidDestroyedSkinPng(defaultDestroyedSkin)) { + ClientPlayNetworking.send(new DestroyedSkinUploadC2SPayload(defaultDestroyedSkin)); + lastUploadedAutoBaseSkinKey = uploadKey; + return true; + } + } + + try { + byte[] bytes = createGeneratedDestroyedSkin(client, baseSkin); + if (bytes != null && isValidDestroyedSkinPng(bytes)) { + ClientPlayNetworking.send(new DestroyedSkinUploadC2SPayload(bytes)); + lastUploadedAutoBaseSkinKey = uploadKey; + return true; + } else { + return false; + } + } catch (NonDestroyedSkinClient.MissingDestroyedSkinFallbackException e) { + markAutomaticSkinUnavailable( + "[NoN] No ripped skin fallback texture was found. Install the default NoN pack or provide {} to enable ripped skin visuals.", + localDestroyedSkinPath() + ); + return false; + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to generate ripped skin fallback. Automatic ripped skin upload is disabled for this connection.", e); + automaticSkinUnavailableThisConnection = true; + uploadRetryTicks = 0; + return false; + } + } + + private static boolean uploadLocalMasks() { + if (!ClientPlayNetworking.canSend(DestroyedSkinMaskUploadC2SPayload.ID)) { + return false; + } + + for (int stage = 1; stage < 4; stage++) { + byte[] selectedBytes = null; + + for (Path path : localOverrideCandidates("destroyed_skin_mask_" + stage + ".png")) { + if (Files.isRegularFile(path)) { + try { + byte[] bytes = Files.readAllBytes(path); + if (isValidMaskPng(bytes)) { + selectedBytes = bytes; + break; + } + + setupWarn("[NoN] Ignoring destroyed skin mask {}: expected a 64x64 PNG.", path); + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to upload local destroyed skin mask from {}.", path, e); + } + } + } + + ClientPlayNetworking.send(new DestroyedSkinMaskUploadC2SPayload(stage, selectedBytes != null ? selectedBytes : new byte[0])); + } + + return true; + } + + public static int detectSkinTintFromCurrentPlayer(int fallbackRgb) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + Identifier baseSkin = client.player.getSkin().body().texturePath(); + if (baseSkin == null) { + return fallbackRgb & 16777215; + } + + try { + NativeImage image = readPlayerTextureImage(client, client.player.getUuid(), baseSkin); + if (image != null && isSupportedStaticSkin(image)) { + NativeImage var4 = image; + + int var6; + try { + NativeImage normalized = scaledCopy(image, 64, 64); + + try { + var6 = detectSkinTint(normalized); + } catch (Throwable var10) { + if (normalized != null) { + try { + normalized.close(); + } catch (Throwable var9) { + var10.addSuppressed(var9); + } + } + + throw var10; + } + + if (normalized != null) { + normalized.close(); + } + } catch (Throwable var11) { + if (var4 != null) { + try { + var4.close(); + } catch (Throwable var8) { + var11.addSuppressed(var8); + } + } + + throw var11; + } + + if (var4 != null) { + var4.close(); + } + + return var6; + } else { + if (image != null) { + image.close(); + } + + return fallbackRgb & 16777215; + } + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to auto-detect destroyed skin tint.", e); + return fallbackRgb & 16777215; + } + } else { + return fallbackRgb & 16777215; + } + } + + public static void clear() { + DESTROYED_SKINS.clear(); + DESTROYED_SKIN_MASKS.clear(); + STAGES.clear(); + MESS_STATES.clear(); + clearResourceCaches(); + uploadRetryTicks = 0; + uploadedThisConnection = false; + uploadedMasksForSelection = false; + automaticSkinUnavailableThisConnection = false; + lastUploadedAutoBaseSkinKey = null; + externalSkinSelectionPollTicks = 0; + skinOverrideCacheVersion = Integer.MIN_VALUE; + OBSERVED_EXTERNAL_SKINS.clear(); + } + + public static void clearResourceCaches() { + MinecraftClient client = MinecraftClient.getInstance(); + List texturesToDestroy = new ArrayList<>(TEXTURE_CACHE.values()); + TEXTURE_CACHE.clear(); + ORIGINAL_SKIN_COLOR_CACHE.clear(); + EFFECTIVE_BASE_TEXTURES.clear(); + FAILED_TEXTURE_READS.clear(); + WARNED_TEXTURE_READS.clear(); + destroyTextures(client, texturesToDestroy); + } + + public static void setSkin(UUID playerUuid, int stage, byte[] pngBytes) { + if (playerUuid != null && pngBytes != null) { + if (pngBytes.length == 0) { + clearCachedTextures(playerUuid); + DESTROYED_SKINS.remove(playerUuid); + EFFECTIVE_BASE_TEXTURES.remove(playerUuid); + setStage(playerUuid, stage); + } else if (!isValidDestroyedSkinPng(pngBytes)) { + setupWarn("[NoN] Ignoring synced destroyed skin for {}: expected a square PNG from 64x64 through 512x512 in 64-pixel steps.", playerUuid); + } else { + clearCachedTextures(playerUuid); + DESTROYED_SKINS.put(playerUuid, (byte[])pngBytes.clone()); + setStage(playerUuid, stage); + } + } + } + + public static void setMask(UUID playerUuid, int stage, byte[] pngBytes) { + if (playerUuid != null && pngBytes != null) { + if (stage > 0 && stage < 4) { + if (pngBytes.length == 0) { + byte[][] masks = DESTROYED_SKIN_MASKS.get(playerUuid); + if (masks != null && stage < masks.length) { + masks[stage] = null; + boolean hasAny = false; + + for (int index = 1; index < masks.length; index++) { + if (masks[index] != null) { + hasAny = true; + break; + } + } + + if (!hasAny) { + DESTROYED_SKIN_MASKS.remove(playerUuid); + } + } + + clearCachedTextures(playerUuid); + } else if (!isValidMaskPng(pngBytes)) { + setupWarn("[NoN] Ignoring synced destroyed skin mask for {} stage {}: expected a 64x64 PNG.", playerUuid, stage); + } else { + byte[][] masks = DESTROYED_SKIN_MASKS.computeIfAbsent(playerUuid, ignored -> new byte[4][]); + masks[stage] = (byte[])pngBytes.clone(); + clearCachedTextures(playerUuid); + } + } + } + } + + public static void setStage(UUID playerUuid, int stage) { + if (playerUuid != null) { + int clamped = clampStage(stage); + if (clamped <= 0) { + STAGES.remove(playerUuid); + } else { + STAGES.put(playerUuid, clamped); + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (clamped > 0 && client != null && client.player != null && playerUuid.equals(client.player.getUuid()) && !DESTROYED_SKINS.containsKey(playerUuid)) { + uploadLocalSkinIfPresent(); + } + + if (client != null && client.player != null && playerUuid.equals(client.player.getUuid())) { + NonWildfireGenderSync.syncDestroyedSkinOverrides(NeedsOfNature.getLocalConfig(), clamped); + } + + clearCachedTextures(playerUuid); + } + } + + public static int getStage(UUID playerUuid) { + return playerUuid == null ? 0 : clampStage(STAGES.getOrDefault(playerUuid, 0)); + } + + @Nullable + public static Integer getLiquidTintRgb(UUID playerUuid) { + if (playerUuid == null) { + return null; + } + + NonDestroyedSkinClient.MessState state = MESS_STATES.get(playerUuid); + return state == null ? null : state.tintRgb(); + } + + public static void setMess(UUID playerUuid, int vMess, int aMess, int mMess, int tintRgb, int liquidStored, int liquidCapacity) { + if (playerUuid != null) { + NonDestroyedSkinClient.MessState state = new NonDestroyedSkinClient.MessState( + clampMess(vMess), clampMess(aMess), clampMess(mMess), tintRgb & 16777215, Math.max(0, liquidStored), Math.max(0, liquidCapacity) + ); + if (!state.hasAnyVisual()) { + MESS_STATES.remove(playerUuid); + } else { + MESS_STATES.put(playerUuid, state); + } + + clearCachedTextures(playerUuid); + } + } + + @Nullable + public static Identifier resolveBaseTexture(LivingEntity entity, Identifier currentBaseTexture) { + int stage = getDestroyedStage(entity); + NonDestroyedSkinClient.MessState messState = getMessState(entity); + List accessoryOverlays = NonAccessorySkinOverlays.resolveEquippedOverlays(entity); + return stage <= 0 && !messState.hasAnyVisual() && accessoryOverlays.isEmpty() ? null : getOrCreateTexture(entity, stage, currentBaseTexture, false); + } + + public static Identifier resolveRenderedTexture(LivingEntity entity, Identifier renderedTexture) { + if (entity != null && renderedTexture != null && !isGeneratedTexture(renderedTexture)) { + rememberEffectiveBaseTexture(entity, renderedTexture); + Identifier replacement = resolveBaseTexture(entity, renderedTexture); + return replacement != null ? replacement : renderedTexture; + } else { + return renderedTexture; + } + } + + @Nullable + public static Identifier resolveOverlayTexture(LivingEntity entity) { + int stage = getDestroyedStage(entity); + return stage > 0 && stage < 4 ? getOrCreateTexture(entity, stage, null, true) : null; + } + + public static void spawnRippedFabricParticles(UUID playerUuid, double x, double y, double z, int count, long seed) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null && playerUuid != null && count > 0) { + AbstractClientPlayerEntity player = null; + + for (AbstractClientPlayerEntity candidate : client.world.getPlayers()) { + if (playerUuid.equals(candidate.getUuid())) { + player = candidate; + break; + } + } + + if (player != null) { + Identifier baseSkin = player.getSkin().body().texturePath(); + int[] colors = getOriginalSkinColors(client, playerUuid, baseSkin); + Random random = Random.create(seed); + int particleCount = Math.max(1, Math.min(12, count)); + + for (int i = 0; i < particleCount; i++) { + int rgb = colors[random.nextInt(colors.length)]; + rgb = varyColor(rgb, random); + double px = x + (random.nextDouble() - 0.5) * 0.16; + double py = y + (random.nextDouble() - 0.5) * 0.2; + double pz = z + (random.nextDouble() - 0.5) * 0.16; + double vx = (random.nextDouble() - 0.5) * 0.001; + double vy = -0.006 - random.nextDouble() * 0.004; + double vz = (random.nextDouble() - 0.5) * 0.001; + Particle particle = RippedFabricParticle.create(client.world, px, py, pz, vx, vy, vz, rgb, random); + if (particle != null) { + client.particleManager.addParticle(particle); + } + } + } + } + } + + private static int getDestroyedStage(LivingEntity entity) { + if (!(entity instanceof PlayerEntity)) { + return 0; + } + + if (entity == null) { + return 0; + } + + if (entity instanceof DestroyedSkinHolder holder) { + int tracked = clampStage(holder.getDestroyedSkinStage()); + if (tracked > 0) { + return tracked; + } + } + + return STAGES.getOrDefault(entity.getUuid(), 0); + } + + private static NonDestroyedSkinClient.MessState getMessState(@Nullable LivingEntity entity) { + return entity == null ? NonDestroyedSkinClient.MessState.EMPTY : MESS_STATES.getOrDefault(entity.getUuid(), NonDestroyedSkinClient.MessState.EMPTY); + } + + @Nullable + private static Identifier getOrCreateTexture(LivingEntity entity, int stage, @Nullable Identifier currentBaseTexture, boolean overlayFallback) { + if (entity == null) { + return null; + } + + UUID playerUuid = entity.getUuid(); + byte[] skin = DESTROYED_SKINS.get(playerUuid); + NonDestroyedSkinClient.MessState messState = MESS_STATES.getOrDefault(playerUuid, NonDestroyedSkinClient.MessState.EMPTY); + List accessoryOverlays = overlayFallback + ? List.of() + : NonAccessorySkinOverlays.resolveEquippedOverlays(entity); + if ((skin == null || skin.length == 0) && !messState.hasAnyVisual() && accessoryOverlays.isEmpty()) { + return null; + } + + NonDestroyedSkinClient.TankMaskType tankMaskType = resolveTankMaskType(entity); + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return null; + } + + int baseTextureIdentity = textureIdentity(client, overlayFallback ? null : currentBaseTexture); + NonExternalSkinCompat.SkinContext externalSkin = overlayFallback ? null : effectiveExternalSkin(client, playerUuid, currentBaseTexture); + String externalSkinKey = externalSkinKey(externalSkin); + NonDestroyedSkinClient.TextureKey key = new NonDestroyedSkinClient.TextureKey( + playerUuid, stage, overlayFallback ? null : currentBaseTexture, baseTextureIdentity, externalSkinKey, messState, tankMaskType, accessoryOverlays + ); + Identifier cached = TEXTURE_CACHE.get(key); + if (cached != null) { + return cached; + } + + try { + NativeImage image; + if (overlayFallback) { + image = createMaskedOverlay(client, playerUuid, skin, stage); + } else { + image = createCompositedSkin(client, playerUuid, skin, stage, currentBaseTexture, externalSkin, messState, tankMaskType, accessoryOverlays); + } + + if (image == null) { + return null; + } + + Identifier id = Identifier.of( + "needsofnature", + "textures/dynamic/destroyed_skin/" + + playerUuid.toString().replace("-", "") + + "_" + + stage + + "_" + + messState.cacheKey() + + "_" + + tankMaskType.id + + "_" + + Integer.toHexString(accessoryOverlays.hashCode()) + + "_" + + Integer.toHexString(Objects.hashCode(currentBaseTexture)) + + "_" + + Integer.toHexString(baseTextureIdentity) + + "_" + + Integer.toHexString(externalSkinKey.hashCode()) + + (overlayFallback ? "_overlay" : "_base") + + ".png" + ); + client.getTextureManager().registerTexture(id, new NativeImageBackedTexture(() -> "non_destroyed_skin", image)); + TEXTURE_CACHE.put(key, id); + return id; + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to create destroyed skin texture for {} stage {}.", playerUuid, stage, e); + return null; + } + } + + @Nullable + private static NativeImage createCompositedSkin( + MinecraftClient client, + UUID playerUuid, + byte[] skin, + int stage, + @Nullable Identifier currentBaseTexture, + @Nullable NonExternalSkinCompat.SkinContext externalSkin, + NonDestroyedSkinClient.MessState messState, + NonDestroyedSkinClient.TankMaskType tankMaskType, + List accessoryOverlays + ) throws IOException { + if (currentBaseTexture == null) { + return null; + } + + NativeImage destroyed = stage > 0 && skin != null && skin.length > 0 ? NativeImage.read(skin) : null; + if (destroyed != null && !isSupportedStaticSkin(destroyed)) { + destroyed.close(); + return null; + } + + if (stage >= 4 && destroyed != null) { + NativeImage var27 = destroyed; + + NativeImage var29; + try { + NativeImage out = new NativeImage(Format.RGBA, destroyed.getWidth(), destroyed.getHeight(), false); + copyScaled(destroyed, out); + applyMessOverlays(client, out, messState); + applyTankOverlay(client, out, messState, tankMaskType); + applyAccessoryOverlays(client, out, accessoryOverlays); + var29 = out; + } catch (Throwable var24) { + if (var27 != null) { + try { + var27.close(); + } catch (Throwable var23) { + var24.addSuppressed(var23); + } + } + + throw var24; + } + + if (var27 != null) { + var27.close(); + } + + return var29; + } else { + NativeImage base = readTextureImage(client, currentBaseTexture, externalSkin); + if (base == null) { + if (destroyed != null) { + destroyed.close(); + } + + return null; + } else if (!isSupportedStaticSkin(base)) { + base.close(); + if (destroyed != null) { + destroyed.close(); + } + + return null; + } else { + NativeImage out = base; + + NativeImage mask; + try { + NativeImage var12 = destroyed; + + try { + int targetSize = destroyed != null ? Math.max(base.getWidth(), destroyed.getWidth()) : base.getWidth(); + NativeImage outx = new NativeImage(Format.RGBA, targetSize, targetSize, false); + copyScaled(base, outx); + if (destroyed != null) { + mask = readMask(client, playerUuid, stage); + if (mask != null) { + for (int y = 0; y < outx.getHeight(); y++) { + for (int x = 0; x < outx.getWidth(); x++) { + int factor = maskFactor(sampleScaled(mask, x, y, outx.getWidth(), outx.getHeight())); + if (factor > 0) { + int baseArgb = outx.getColorArgb(x, y); + int destroyedArgb = sampleScaled(destroyed, x, y, outx.getWidth(), outx.getHeight()); + outx.setColorArgb(x, y, blendArgb(baseArgb, destroyedArgb, factor)); + } + } + } + + mask.close(); + } + } + + applyMessOverlays(client, outx, messState); + applyTankOverlay(client, outx, messState, tankMaskType); + applyAccessoryOverlays(client, outx, accessoryOverlays); + mask = outx; + } catch (Throwable var25) { + if (var12 != null) { + try { + var12.close(); + } catch (Throwable var22) { + var25.addSuppressed(var22); + } + } + + throw var25; + } + + if (var12 != null) { + var12.close(); + } + } catch (Throwable var26) { + if (out != null) { + try { + out.close(); + } catch (Throwable var21) { + var26.addSuppressed(var21); + } + } + + throw var26; + } + + if (out != null) { + out.close(); + } + + return mask; + } + } + } + + @Nullable + public static NativeImage readTextureImage(MinecraftClient client, Identifier textureId) throws IOException { + UUID playerUuid = client != null && client.player != null ? client.player.getUuid() : null; + NonExternalSkinCompat.SkinContext externalSkin = playerUuid != null ? effectiveExternalSkin(client, playerUuid, textureId) : null; + return readTextureImage(client, textureId, externalSkin); + } + + @Nullable + private static NativeImage readPlayerTextureImage(MinecraftClient client, UUID playerUuid, Identifier textureId) throws IOException { + return readTextureImage(client, textureId, effectiveExternalSkin(client, playerUuid, textureId)); + } + + @Nullable + private static NativeImage readTextureImage(MinecraftClient client, Identifier textureId, @Nullable NonExternalSkinCompat.SkinContext externalSkin) throws IOException { + if (client != null && textureId != null) { + Resource resource = (Resource)client.getResourceManager().getResource(textureId).orElse(null); + if (resource != null) { + try (InputStream in = resource.getInputStream()) { + return NativeImage.read(in); + } + } else { + int identity = textureIdentity(client, textureId); + NonDestroyedSkinClient.TextureReadKey readKey = new NonDestroyedSkinClient.TextureReadKey(textureId, identity, externalSkinKey(externalSkin)); + Long retryAt = FAILED_TEXTURE_READS.get(readKey); + if (retryAt != null && System.nanoTime() < retryAt) { + return null; + } + + Throwable sourceFailure = null; + Path sourcePath = externalSkin != null ? externalSkin.sourcePath() : null; + if (sourcePath != null && Files.isRegularFile(sourcePath)) { + try (InputStream in = Files.newInputStream(sourcePath)) { + NativeImage image = NativeImage.read(in); + clearTextureReadFailure(readKey); + return image; + } catch (IOException | RuntimeException e) { + sourceFailure = e; + } + } + + if (!(client.getTextureManager().getTexture(textureId) instanceof NativeImageBackedTexture backedTexture)) { + if (sourceFailure != null) { + recordTextureReadFailure(readKey, sourcePath, sourceFailure); + } + + return null; + } else { + NativeImage image = backedTexture.getImage(); + if (image == null) { + recordTextureReadFailure(readKey, sourcePath, sourceFailure); + return null; + } + + NativeImage copy = null; + + try { + copy = new NativeImage(Format.RGBA, image.getWidth(), image.getHeight(), false); + copy.copyFrom(image); + clearTextureReadFailure(readKey); + return copy; + } catch (RuntimeException e) { + if (copy != null) { + copy.close(); + } + + if (sourceFailure != null) { + e.addSuppressed(sourceFailure); + } + + recordTextureReadFailure(readKey, sourcePath, e); + return null; + } + } + } + } else { + return null; + } + } + + private static boolean isGeneratedTexture(Identifier textureId) { + return "needsofnature".equals(textureId.getNamespace()) && textureId.getPath().startsWith("textures/dynamic/destroyed_skin/"); + } + + private static boolean isSupportedStaticSkin(NativeImage image) { + int width = image.getWidth(); + return width >= 64 && width == image.getHeight() && width % 64 == 0; + } + + private static int textureIdentity(MinecraftClient client, @Nullable Identifier textureId) { + return textureId == null ? 0 : System.identityHashCode(client.getTextureManager().getTexture(textureId)); + } + + @Nullable + private static NonExternalSkinCompat.SkinContext effectiveExternalSkin(MinecraftClient client, UUID playerUuid, @Nullable Identifier textureId) { + if (client != null && playerUuid != null && textureId != null) { + NonDestroyedSkinClient.EffectiveBaseTexture effective = EFFECTIVE_BASE_TEXTURES.get(playerUuid); + return effective != null && textureId.equals(effective.id()) ? effective.externalSkin() : NonExternalSkinCompat.resolve(client, playerUuid, textureId); + } else { + return null; + } + } + + private static String externalSkinKey(@Nullable NonExternalSkinCompat.SkinContext externalSkin) { + return externalSkin != null ? externalSkin.cacheKey() : ""; + } + + private static void recordTextureReadFailure(NonDestroyedSkinClient.TextureReadKey key, @Nullable Path sourcePath, @Nullable Throwable error) { + FAILED_TEXTURE_READS.put(key, System.nanoTime() + 1000000000L); + if (error != null && WARNED_TEXTURE_READS.add(key)) { + setupWarn( + "[NoN] Failed to read skin texture {}{}; ripped-skin composition will retry after a short delay.", + key.textureId(), + sourcePath != null ? " from " + sourcePath : "", + error + ); + } + } + + private static void clearTextureReadFailure(NonDestroyedSkinClient.TextureReadKey key) { + FAILED_TEXTURE_READS.remove(key); + } + + private static void clearTextureReadFailures(Identifier textureId) { + FAILED_TEXTURE_READS.keySet().removeIf(key -> key.textureId().equals(textureId)); + WARNED_TEXTURE_READS.removeIf(key -> key.textureId().equals(textureId)); + } + + private static void removeOriginalSkinColors(Identifier textureId) { + ORIGINAL_SKIN_COLOR_CACHE.keySet().removeIf(key -> key.textureId().equals(textureId)); + } + + private static void rememberEffectiveBaseTexture(LivingEntity entity, Identifier textureId) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + UUID playerUuid = entity.getUuid(); + int identity = textureIdentity(client, textureId); + NonDestroyedSkinClient.EffectiveBaseTexture previous = EFFECTIVE_BASE_TEXTURES.get(playerUuid); + if (previous == null || !previous.id().equals(textureId) || previous.textureIdentity() != identity) { + NonExternalSkinCompat.SkinContext externalSkin = NonExternalSkinCompat.resolve(client, playerUuid, textureId); + NonDestroyedSkinClient.EffectiveBaseTexture current = new NonDestroyedSkinClient.EffectiveBaseTexture(textureId, identity, externalSkin); + previous = EFFECTIVE_BASE_TEXTURES.put(playerUuid, current); + if (previous != null && !previous.equals(current)) { + removeOriginalSkinColors(previous.id()); + clearTextureReadFailures(previous.id()); + clearCachedTextures(playerUuid); + if (client.player != null && playerUuid.equals(client.player.getUuid()) && shouldRetryLocalSkinUpload(previous, current)) { + scheduleLocalSkinUploadRetry(); + } + } + } + } + } + + private static boolean shouldRetryLocalSkinUpload(NonDestroyedSkinClient.EffectiveBaseTexture previous, NonDestroyedSkinClient.EffectiveBaseTexture current) { + String previousSelection = externalSelectionKey(previous.externalSkin()); + String currentSelection = externalSelectionKey(current.externalSkin()); + return !previousSelection.equals(currentSelection) + ? true + : previousSelection.isEmpty() && (!previous.id().equals(current.id()) || previous.textureIdentity() != current.textureIdentity()); + } + + private static String externalSelectionKey(@Nullable NonExternalSkinCompat.SkinContext externalSkin) { + return externalSkin != null ? externalSkin.selectionKey() : ""; + } + + private static boolean hasProfileTextureProperties(MinecraftClient client) { + if (client != null && client.player != null && client.player.getGameProfile() != null) { + try { + if (client.player.getGameProfile().getClass().getMethod("getProperties").invoke(client.player.getGameProfile()) instanceof Map map) { + return map.containsKey("textures"); + } + } catch (ReflectiveOperationException | RuntimeException var4) { + } + + try { + if (client.player.getGameProfile().getClass().getMethod("properties").invoke(client.player.getGameProfile()) instanceof Map map) { + return map.containsKey("textures"); + } + } catch (ReflectiveOperationException | RuntimeException var3) { + } + + return false; + } else { + return false; + } + } + + private static boolean isLikelyDefaultPlayerSkin(Identifier textureId) { + if (textureId != null && "minecraft".equals(textureId.getNamespace())) { + String path = textureId.getPath(); + return path != null && path.startsWith("textures/entity/player/"); + } else { + return false; + } + } + + public static int sendDetectedRippedSkinInfo() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + Identifier baseSkin = client.player.getSkin().body().texturePath(); + boolean defaultSkinPath = isLikelyDefaultPlayerSkin(baseSkin); + boolean profileHasCustomTexture = hasProfileTextureProperties(client); + NonDestroyedSkinClient.DefaultSkinKey key = parseDefaultSkinKey(baseSkin); + NonDestroyedSkinClient.DefaultSkinMatch pixelMatch = detectDefaultSkinMatchByPixels(client, baseSkin); + NonDestroyedSkinClient.DefaultSkinKey pixelKey = pixelMatch == null ? null : pixelMatch.key(); + NonDestroyedSkinClient.DefaultSkinKey resolvedKey = key != null ? key : (pixelMatch != null && pixelMatch.accepted() ? pixelMatch.key() : null); + PlayerSkinType model = client.player.getSkin() == null ? null : client.player.getSkin().model(); + String profileName = client.player.getName().getString(); + UUID uuid = client.player.getUuid(); + NonDestroyedSkinClient.DefaultSkinCandidate defaultDestroyed = resolvedKey == null ? null : describeDefaultDestroyedSkinCandidate(client, resolvedKey); + String message = "[NoN RippedSkinInfo] profile=" + + profileName + + " uuid=" + + uuid + + " base=" + + baseSkin + + " model=" + + model + + " defaultPath=" + + defaultSkinPath + + " profileTextures=" + + profileHasCustomTexture + + " parsed=" + + (key == null ? "" : key.model() + "/" + key.name()) + + " pixel=" + + (pixelKey == null ? "" : pixelKey.model() + "/" + pixelKey.name()) + + " confidence=" + + (pixelMatch == null ? "" : String.format(Locale.ROOT, "%.1f%%", pixelMatch.confidence() * 100.0)) + + " accepted=" + + (pixelMatch != null && pixelMatch.accepted()) + + " resolved=" + + (resolvedKey == null ? "" : resolvedKey.model() + "/" + resolvedKey.name()) + + " candidate=" + + (defaultDestroyed == null ? "" : defaultDestroyed.logString()); + NeedsOfNature.LOGGER.info(message); + sendRippedSkinInfoLine(client, "NoN Ripped Skin Info", Formatting.GOLD); + sendRippedSkinInfoLine(client, "Player: " + profileName + " (" + uuid + ")", Formatting.GRAY); + sendRippedSkinInfoLine(client, "Base texture: " + baseSkin, Formatting.WHITE); + sendRippedSkinInfoLine(client, "Model: " + model, Formatting.WHITE); + sendRippedSkinInfoLine(client, "Default path: " + defaultSkinPath + " | Profile textures: " + profileHasCustomTexture, Formatting.WHITE); + sendRippedSkinInfoLine(client, "Parsed key: " + formatDefaultSkinKey(key), Formatting.WHITE); + sendRippedSkinInfoLine( + client, + "Pixel match: " + + formatDefaultSkinKey(pixelKey) + + " | Confidence: " + + (pixelMatch == null ? "" : String.format(Locale.ROOT, "%.1f%%", pixelMatch.confidence() * 100.0)) + + " | Accepted: " + + (pixelMatch != null && pixelMatch.accepted()), + pixelMatch != null && pixelMatch.accepted() ? Formatting.GREEN : Formatting.YELLOW + ); + sendRippedSkinInfoLine(client, "Resolved key: " + formatDefaultSkinKey(resolvedKey), resolvedKey == null ? Formatting.RED : Formatting.GREEN); + if (defaultDestroyed == null) { + sendRippedSkinInfoLine(client, "Ripped candidates: ", Formatting.RED); + } else { + sendRippedSkinInfoLine( + client, + "Ripped candidate: " + defaultDestroyed.genderSpecific() + " = " + defaultDestroyed.genderFound(), + defaultDestroyed.genderFound() ? Formatting.GREEN : Formatting.YELLOW + ); + sendRippedSkinInfoLine( + client, + "Fallback candidate: " + defaultDestroyed.generic() + " = " + defaultDestroyed.genericFound(), + defaultDestroyed.genericFound() ? Formatting.GREEN : Formatting.GRAY + ); + } + + return 1; + } else { + return 0; + } + } + + private static void sendRippedSkinInfoLine(MinecraftClient client, String message, Formatting color) { + client.player.sendMessage(Text.literal(message).formatted(color), false); + } + + private static String formatDefaultSkinKey(@Nullable NonDestroyedSkinClient.DefaultSkinKey key) { + return key == null ? "" : key.model() + "/" + key.name(); + } + + private static NonDestroyedSkinClient.DefaultSkinCandidate describeDefaultDestroyedSkinCandidate( + MinecraftClient client, NonDestroyedSkinClient.DefaultSkinKey key + ) { + String genderSuffix = NeedsOfNature.getLocalConfig().getPlayerGenderSelection() == NonConfig.PlayerGenderSelection.MALE ? "_m" : "_f"; + Identifier genderSpecific = defaultDestroyedSkinId(key, genderSuffix); + Identifier generic = defaultDestroyedSkinId(key, ""); + boolean genderFound = client.getResourceManager().getResource(genderSpecific).isPresent(); + boolean genericFound = client.getResourceManager().getResource(generic).isPresent(); + return new NonDestroyedSkinClient.DefaultSkinCandidate(genderSpecific, genderFound, generic, genericFound); + } + + @Nullable + private static byte[] readDefaultDestroyedSkin(MinecraftClient client, NonDestroyedSkinClient.DefaultSkinKey key) { + if (client != null && key != null) { + String genderSuffix = NeedsOfNature.getLocalConfig().getPlayerGenderSelection() == NonConfig.PlayerGenderSelection.MALE ? "_m" : "_f"; + byte[] genderSpecific = readDefaultDestroyedSkin(client, key, genderSuffix); + return genderSpecific != null ? genderSpecific : readDefaultDestroyedSkin(client, key, ""); + } else { + return null; + } + } + + @Nullable + private static byte[] readDefaultDestroyedSkin(MinecraftClient client, NonDestroyedSkinClient.DefaultSkinKey key, String suffix) { + Identifier destroyedId = defaultDestroyedSkinId(key, suffix); + Resource resource = (Resource)client.getResourceManager().getResource(destroyedId).orElse(null); + if (resource == null) { + return null; + } + + try (InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to read default destroyed skin {}.", destroyedId, e); + return null; + } + } + + private static Identifier defaultDestroyedSkinId(NonDestroyedSkinClient.DefaultSkinKey key, String suffix) { + return Identifier.of("needsofnature", "textures/player_destroyed/default/" + key.model() + "/" + key.name() + suffix + ".png"); + } + + @Nullable + private static NonDestroyedSkinClient.DefaultSkinKey resolveDefaultSkinKey(MinecraftClient client, Identifier baseSkin) { + NonDestroyedSkinClient.DefaultSkinKey parsed = parseDefaultSkinKey(baseSkin); + if (parsed != null) { + return parsed; + } + + NonDestroyedSkinClient.DefaultSkinMatch match = detectDefaultSkinMatchByPixels(client, baseSkin); + return match != null && match.accepted() ? match.key() : null; + } + + @Nullable + private static NonDestroyedSkinClient.DefaultSkinKey parseDefaultSkinKey(Identifier baseSkin) { + if (baseSkin != null && "minecraft".equals(baseSkin.getNamespace())) { + String path = baseSkin.getPath(); + if (path != null && path.startsWith("textures/entity/player/")) { + String suffix = path.substring("textures/entity/player/".length()); + if (suffix.endsWith(".png")) { + suffix = suffix.substring(0, suffix.length() - ".png".length()); + } + + String[] parts = suffix.split("/"); + if (parts.length != 2) { + return null; + } else { + String model = parts[0]; + String name = parts[1]; + if (!"slim".equals(model) && !"wide".equals(model)) { + return null; + } else { + return name.isBlank() ? null : new NonDestroyedSkinClient.DefaultSkinKey(model, name); + } + } + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static NonDestroyedSkinClient.DefaultSkinMatch detectDefaultSkinMatchByPixels(MinecraftClient client, Identifier baseSkin) { + if (client != null && baseSkin != null) { + PlayerSkinType currentModel = client.player != null && client.player.getSkin() != null ? client.player.getSkin().model() : PlayerSkinType.SLIM; + String preferredModel = currentModel == PlayerSkinType.WIDE ? "wide" : "slim"; + String fallbackModel = "wide".equals(preferredModel) ? "slim" : "wide"; + UUID playerUuid = client.player != null ? client.player.getUuid() : null; + + try { + NativeImage base = playerUuid != null ? readPlayerTextureImage(client, playerUuid, baseSkin) : readTextureImage(client, baseSkin, null); + + NonDestroyedSkinClient.DefaultSkinMatch var17; + label151: { + label152: { + label153: { + label154: { + label155: { + NonDestroyedSkinClient.DefaultSkinMatch preferred; + try { + if (base != null && base.getWidth() == 64 && base.getHeight() == 64) { + preferred = detectDefaultSkinMatchByPixels(client, base, preferredModel); + NonDestroyedSkinClient.DefaultSkinMatch fallback = detectDefaultSkinMatchByPixels(client, base, fallbackModel); + if (preferred == null) { + var17 = fallback; + break label151; + } + + if (fallback == null) { + var17 = preferred; + break label152; + } + + if (preferred.accepted() && !fallback.accepted()) { + var17 = preferred; + break label153; + } + + if (fallback.accepted() && !preferred.accepted()) { + var17 = fallback; + break label154; + } + + var17 = preferred.confidence() >= fallback.confidence() ? preferred : fallback; + break label155; + } + + preferred = null; + } catch (Throwable var11) { + if (base != null) { + try { + base.close(); + } catch (Throwable var10) { + var11.addSuppressed(var10); + } + } + + throw var11; + } + + if (base != null) { + base.close(); + } + + return preferred; + } + + if (base != null) { + base.close(); + } + + return var17; + } + + if (base != null) { + base.close(); + } + + return var17; + } + + if (base != null) { + base.close(); + } + + return var17; + } + + if (base != null) { + base.close(); + } + + return var17; + } + + if (base != null) { + base.close(); + } + + return var17; + } catch (IOException | RuntimeException e) { + return null; + } + } else { + return null; + } + } + + @Nullable + private static NonDestroyedSkinClient.DefaultSkinMatch detectDefaultSkinMatchByPixels(MinecraftClient client, NativeImage base, String model) { + NonDestroyedSkinClient.DefaultSkinMatch best = null; + + for (String name : VANILLA_DEFAULT_SKIN_NAMES) { + Identifier vanillaId = Identifier.ofVanilla("textures/entity/player/" + model + "/" + name + ".png"); + + try { + NativeImage vanilla = readTextureImage(client, vanillaId); + + try { + if (vanilla != null) { + double confidence = imageMatchConfidence(base, vanilla); + if (best == null || confidence > best.confidence()) { + best = new NonDestroyedSkinClient.DefaultSkinMatch(new NonDestroyedSkinClient.DefaultSkinKey(model, name), confidence); + } + } + } catch (Throwable var13) { + if (vanilla != null) { + try { + vanilla.close(); + } catch (Throwable var12) { + var13.addSuppressed(var12); + } + } + + throw var13; + } + + if (vanilla != null) { + vanilla.close(); + } + } catch (IOException | RuntimeException var14) { + } + } + + return best; + } + + private static double imageMatchConfidence(NativeImage first, NativeImage second) { + if (first != null && second != null) { + if (first.getWidth() == second.getWidth() && first.getHeight() == second.getHeight()) { + int checked = 0; + int matched = 0; + + for (int y = 0; y < first.getHeight(); y++) { + for (int x = 0; x < first.getWidth(); x++) { + int firstArgb = first.getColorArgb(x, y); + int secondArgb = second.getColorArgb(x, y); + int firstAlpha = firstArgb >>> 24 & 0xFF; + int secondAlpha = secondArgb >>> 24 & 0xFF; + if (firstAlpha != 0 || secondAlpha != 0) { + checked++; + if (firstArgb == secondArgb) { + matched++; + } + } + } + } + + return checked <= 0 ? 0.0 : (double)matched / checked; + } else { + return 0.0; + } + } else { + return 0.0; + } + } + + @Nullable + private static NativeImage createMaskedOverlay(MinecraftClient client, UUID playerUuid, byte[] skin, int stage) throws IOException { + NativeImage destroyed = NativeImage.read(skin); + NativeImage mask = readMask(client, playerUuid, stage); + if (mask == null) { + destroyed.close(); + return null; + } + + NativeImage out = new NativeImage(Format.RGBA, destroyed.getWidth(), destroyed.getHeight(), false); + + for (int y = 0; y < destroyed.getHeight(); y++) { + for (int x = 0; x < destroyed.getWidth(); x++) { + int skinArgb = destroyed.getColorArgb(x, y); + int maskArgb = sampleScaled(mask, x, y, destroyed.getWidth(), destroyed.getHeight()); + int factor = maskFactor(maskArgb); + int alpha = (skinArgb >>> 24 & 0xFF) * factor / 255; + out.setColorArgb(x, y, skinArgb & 16777215 | alpha << 24); + } + } + + destroyed.close(); + mask.close(); + return out; + } + + private static byte[] createGeneratedDestroyedSkin(MinecraftClient client, Identifier baseSkin) throws IOException { + NativeImage base = client != null && client.player != null + ? readPlayerTextureImage(client, client.player.getUuid(), baseSkin) + : readTextureImage(client, baseSkin, null); + if (base == null) { + return null; + } + + PlayerSkinType model = client != null && client.player != null && client.player.getSkin() != null ? client.player.getSkin().model() : PlayerSkinType.SLIM; + NativeImage var4 = base; + + Object normalizedBase; + label154: { + byte[] var25; + try { + NativeImage destroyedOverlay = readDestroyedBaseTexture(client, model); + + label156: { + try { + if (isSupportedStaticSkin(base)) { + NativeImage normalizedBasex = scaledCopy(base, 64, 64); + + try { + NativeImage out = new NativeImage(Format.RGBA, 64, 64, false); + + try { + int detectedTint = detectSkinTint(normalizedBasex); + NonConfig config = NeedsOfNature.getLocalConfig(); + int tint = config.getDestroyedSkinTintRgb(detectedTint); + if (!config.hasDestroyedSkinTintOverride()) { + config.setDestroyedSkinTintHex(formatRgb(tint)); + config.save(); + } + + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + int overlayArgb = sampleScaled(destroyedOverlay, x, y, 64, 64); + int overlayAlpha = overlayArgb >>> 24 & 0xFF; + int brightness = ((overlayArgb >>> 16 & 0xFF) + (overlayArgb >>> 8 & 0xFF) + (overlayArgb & 0xFF)) / 3; + out.setColorArgb(x, y, tintByBrightness(tint, brightness, overlayAlpha)); + } + } + + copyRegion(normalizedBasex, out, 0, 0, 64, 16); + var25 = writeImageToBytes(out); + } catch (Throwable var20) { + try { + out.close(); + } catch (Throwable var19) { + var20.addSuppressed(var19); + } + + throw var20; + } + + out.close(); + } catch (Throwable var21) { + if (normalizedBasex != null) { + try { + normalizedBasex.close(); + } catch (Throwable var18) { + var21.addSuppressed(var18); + } + } + + throw var21; + } + + if (normalizedBasex != null) { + normalizedBasex.close(); + } + break label156; + } + + normalizedBase = null; + } catch (Throwable var22) { + if (destroyedOverlay != null) { + try { + destroyedOverlay.close(); + } catch (Throwable var17) { + var22.addSuppressed(var17); + } + } + + throw var22; + } + + if (destroyedOverlay != null) { + destroyedOverlay.close(); + } + break label154; + } + + if (destroyedOverlay != null) { + destroyedOverlay.close(); + } + } catch (Throwable var23) { + if (var4 != null) { + try { + var4.close(); + } catch (Throwable var16) { + var23.addSuppressed(var16); + } + } + + throw var23; + } + + if (var4 != null) { + var4.close(); + } + + return var25; + } + + if (var4 != null) { + var4.close(); + } + + return (byte[])normalizedBase; + } + + private static NativeImage readDestroyedBaseTexture(MinecraftClient client, PlayerSkinType model) throws IOException { + boolean useMaleTexture = NeedsOfNature.getLocalConfig().getPlayerGenderSelection() == NonConfig.PlayerGenderSelection.MALE; + boolean wide = model == PlayerSkinType.WIDE; + Identifier bundledModel = defaultDestroyedTexture(useMaleTexture, wide); + NativeImage bundledModelImage = readBundledDestroyedTexture(client, bundledModel); + if (bundledModelImage != null) { + return bundledModelImage; + } else { + Identifier bundledGender = useMaleTexture ? DEFAULT_DESTROYED_TEXTURE_M : DEFAULT_DESTROYED_TEXTURE_F; + NativeImage bundledGenderImage = readBundledDestroyedTexture(client, bundledGender); + if (bundledGenderImage != null) { + return bundledGenderImage; + } else { + throw new NonDestroyedSkinClient.MissingDestroyedSkinFallbackException( + "Missing ripped skin fallback texture " + bundledModel + " and fallback " + bundledGender + ); + } + } + } + + @Nullable + private static NativeImage readBundledDestroyedTexture(MinecraftClient client, Identifier id) throws IOException { + Resource resource = (Resource)client.getResourceManager().getResource(id).orElse(null); + if (resource == null) { + return null; + } + + try (InputStream in = resource.getInputStream()) { + return NativeImage.read(in); + } + } + + private static Identifier defaultDestroyedTexture(boolean useMaleTexture, boolean wide) { + if (useMaleTexture) { + return wide ? DEFAULT_DESTROYED_TEXTURE_M_WIDE : DEFAULT_DESTROYED_TEXTURE_M_SLIM; + } else { + return wide ? DEFAULT_DESTROYED_TEXTURE_F_WIDE : DEFAULT_DESTROYED_TEXTURE_F_SLIM; + } + } + + private static void copyRegion(NativeImage source, NativeImage target, int minX, int minY, int maxX, int maxY) { + int endX = Math.min(Math.min(source.getWidth(), target.getWidth()), maxX); + int endY = Math.min(Math.min(source.getHeight(), target.getHeight()), maxY); + + for (int y = Math.max(0, minY); y < endY; y++) { + for (int x = Math.max(0, minX); x < endX; x++) { + target.setColorArgb(x, y, source.getColorArgb(x, y)); + } + } + } + + private static byte[] writeImageToBytes(NativeImage image) throws IOException { + Path temp = Files.createTempFile("non_destroyed_skin", ".png"); + + try { + image.writeTo(temp); + return Files.readAllBytes(temp); + } finally { + try { + Files.deleteIfExists(temp); + } catch (IOException var9) { + } + } + } + + private static int detectSkinTint(NativeImage skin) { + Map buckets = new HashMap<>(); + addTintSamples(skin, buckets, 9, 12, 15, 16, 5); + addTintSamples(skin, buckets, 8, 12, 16, 16, 2); + addTintSamples(skin, buckets, 20, 17, 28, 24, 3); + addTintSamples(skin, buckets, 40, 20, 54, 32, 1); + addTintSamples(skin, buckets, 32, 56, 46, 64, 1); + if (buckets.isEmpty()) { + return 13803641; + } + + List topBuckets = new ArrayList<>(buckets.values()); + topBuckets.sort((a, bx) -> Integer.compare(bx.count, a.count)); + int limit = Math.min(3, topBuckets.size()); + long r = 0L; + long g = 0L; + long b = 0L; + int totalWeight = 0; + + for (int i = 0; i < limit; i++) { + NonDestroyedSkinClient.ColorBucket bucket = topBuckets.get(i); + int weight = Math.max(1, bucket.count); + r += (long)bucket.averageR() * weight; + g += (long)bucket.averageG() * weight; + b += (long)bucket.averageB() * weight; + totalWeight += weight; + } + + return totalWeight <= 0 ? 13803641 : (int)(r / totalWeight) << 16 | (int)(g / totalWeight) << 8 | (int)(b / totalWeight); + } + + private static void addTintSamples( + NativeImage skin, Map out, int minX, int minY, int maxX, int maxY, int weight + ) { + int clampedMaxX = Math.min(maxX, skin.getWidth()); + int clampedMaxY = Math.min(maxY, skin.getHeight()); + + for (int y = Math.max(0, minY); y < clampedMaxY; y++) { + for (int x = Math.max(0, minX); x < clampedMaxX; x++) { + int argb = skin.getColorArgb(x, y); + if (isSkinTintCandidate(argb)) { + int rgb = argb & 16777215; + int key = colorBucketKey(rgb); + out.computeIfAbsent(key, ignored -> new NonDestroyedSkinClient.ColorBucket()).add(rgb, Math.max(1, weight)); + } + } + } + } + + private static int colorBucketKey(int rgb) { + int r = (rgb >>> 16 & 0xFF) / 8; + int g = (rgb >>> 8 & 0xFF) / 8; + int b = (rgb & 0xFF) / 8; + return r << 10 | g << 5 | b; + } + + private static boolean isSkinTintCandidate(int argb) { + if ((argb >>> 24 & 0xFF) < 32) { + return false; + } else { + int r = argb >>> 16 & 0xFF; + int g = argb >>> 8 & 0xFF; + int b = argb & 0xFF; + int max = Math.max(r, Math.max(g, b)); + int min = Math.min(r, Math.min(g, b)); + int brightness = (r + g + b) / 3; + if (brightness >= 35 && brightness <= 245) { + float saturation = max == 0 ? 0.0F : (float)(max - min) / max; + return saturation > 0.7F ? false : r >= b * 0.72F && g >= b * 0.5F; + } else { + return false; + } + } + } + + private static int brightness(int rgb) { + return ((rgb >>> 16 & 0xFF) + (rgb >>> 8 & 0xFF) + (rgb & 0xFF)) / 3; + } + + private static int tintByBrightness(int tintRgb, int brightness, int alpha) { + float shade = Math.max(0.0F, Math.min(1.0F, brightness / 255.0F)); + int r = Math.round((tintRgb >>> 16 & 0xFF) * shade); + int g = Math.round((tintRgb >>> 8 & 0xFF) * shade); + int b = Math.round((tintRgb & 0xFF) * shade); + return Math.max(0, Math.min(255, alpha)) << 24 | r << 16 | g << 8 | b; + } + + private static String formatRgb(int rgb) { + return String.format(Locale.ROOT, "%06X", rgb & 16777215); + } + + @Nullable + private static NativeImage readMask(MinecraftClient client, UUID playerUuid, int stage) { + if (stage > 0 && stage < 4) { + byte[][] syncedMasks = DESTROYED_SKIN_MASKS.get(playerUuid); + if (syncedMasks != null && stage < syncedMasks.length) { + byte[] synced = syncedMasks[stage]; + if (synced != null && synced.length > 0) { + try { + return NativeImage.read(synced); + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to read synced destroyed skin mask for {} stage {}.", playerUuid, stage, e); + } + } + } + + Identifier maskId = MASK_TEXTURES[stage]; + if (maskId == null) { + return null; + } + + try { + Resource resource = (Resource)client.getResourceManager().getResource(maskId).orElse(null); + if (resource == null) { + return null; + } + + try (InputStream in = resource.getInputStream()) { + return NativeImage.read(in); + } + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to read destroyed skin mask {}.", maskId, e); + return null; + } + } else { + return null; + } + } + + private static int maskFactor(int maskArgb) { + int maskAlpha = maskArgb >>> 24 & 0xFF; + int maskBrightness = ((maskArgb >>> 16 & 0xFF) + (maskArgb >>> 8 & 0xFF) + (maskArgb & 0xFF)) / 3; + return maskAlpha * maskBrightness / 255; + } + + private static int blendArgb(int baseArgb, int overlayArgb, int factor) { + int inv = 255 - factor; + int a = ((baseArgb >>> 24 & 0xFF) * inv + (overlayArgb >>> 24 & 0xFF) * factor) / 255; + int r = ((baseArgb >>> 16 & 0xFF) * inv + (overlayArgb >>> 16 & 0xFF) * factor) / 255; + int g = ((baseArgb >>> 8 & 0xFF) * inv + (overlayArgb >>> 8 & 0xFF) * factor) / 255; + int b = ((baseArgb & 0xFF) * inv + (overlayArgb & 0xFF) * factor) / 255; + return a << 24 | r << 16 | g << 8 | b; + } + + private static void applyMessOverlays(MinecraftClient client, NativeImage out, NonDestroyedSkinClient.MessState state) { + if (client != null && out != null && state != null && state.hasAnyMess()) { + applyMessRole(client, out, state.vMess(), state.tintRgb(), MESS_MASK_TEXTURES[0]); + applyMessRole(client, out, state.aMess(), state.tintRgb(), MESS_MASK_TEXTURES[1]); + applyMessRole(client, out, state.mMess(), state.tintRgb(), MESS_MASK_TEXTURES[2]); + } + } + + private static void applyTankOverlay( + MinecraftClient client, NativeImage out, NonDestroyedSkinClient.MessState state, NonDestroyedSkinClient.TankMaskType tankMaskType + ) { + if (client != null && out != null && state != null && state.hasTankOverlay()) { + Identifier maskId = tankMaskType == NonDestroyedSkinClient.TankMaskType.A ? A_MESS_TANK_TEXTURE : V_MESS_TANK_TEXTURE; + NativeImage mask = readMessMask(client, maskId); + if (mask != null) { + applyTintMask(out, mask, state.tintRgb(), state.tankAlpha()); + mask.close(); + } + } + } + + private static void applyAccessoryOverlays(MinecraftClient client, NativeImage out, List overlays) { + if (client != null && out != null && overlays != null && !overlays.isEmpty()) { + for (NonTrinketsIntegration.ResolvedVisualLayer layer : overlays) { + if (layer != null && layer.texture() != null) { + NativeImage overlay = readAccessoryOverlay(client, layer.texture()); + if (overlay != null) { + applyRawOverlay(out, overlay, layer.tintRgb()); + overlay.close(); + } + } + } + } + } + + @Nullable + private static NativeImage readAccessoryOverlay(MinecraftClient client, Identifier overlayId) { + if (client != null && overlayId != null) { + try { + Resource resource = (Resource)client.getResourceManager().getResource(overlayId).orElse(null); + if (resource == null) { + return null; + } + + try (InputStream in = resource.getInputStream()) { + NativeImage image = NativeImage.read(in); + if (image.getWidth() == 64 && image.getHeight() == 64) { + return image; + } + + image.close(); + setupWarn("[NoN] Ignoring accessory skin overlay {}: expected 64x64 PNG.", overlayId); + return null; + } + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to read accessory skin overlay {}.", overlayId, e); + return null; + } + } else { + return null; + } + } + + private static void applyRawOverlay(NativeImage out, NativeImage overlay, int tintRgb) { + int tintR = tintRgb >>> 16 & 0xFF; + int tintG = tintRgb >>> 8 & 0xFF; + int tintB = tintRgb & 0xFF; + + for (int y = 0; y < out.getHeight(); y++) { + for (int x = 0; x < out.getWidth(); x++) { + int overlayArgb = sampleScaled(overlay, x, y, out.getWidth(), out.getHeight()); + int alpha = overlayArgb >>> 24 & 0xFF; + if (alpha > 0) { + int red = (overlayArgb >>> 16 & 0xFF) * tintR / 255; + int green = (overlayArgb >>> 8 & 0xFF) * tintG / 255; + int blue = (overlayArgb & 0xFF) * tintB / 255; + overlayArgb = alpha << 24 | red << 16 | green << 8 | blue; + out.setColorArgb(x, y, blendArgb(out.getColorArgb(x, y), overlayArgb, alpha)); + } + } + } + } + + private static void applyMessRole(MinecraftClient client, NativeImage out, int mess, int tintRgb, Identifier[] masks) { + if (mess > 0 && masks != null && masks.length >= 3) { + for (NonDestroyedSkinClient.MessLayer layer : messLayers(mess)) { + if (layer.index() >= 0 && layer.index() < masks.length) { + NativeImage mask = readMessMask(client, masks[layer.index()]); + if (mask != null) { + applyTintMask(out, mask, tintRgb, layer.alpha()); + mask.close(); + } + } + } + } + } + + private static List messLayers(int mess) { + return switch (clampMess(mess)) { + case 1 -> List.of(new NonDestroyedSkinClient.MessLayer(0, 85)); + case 2 -> List.of(new NonDestroyedSkinClient.MessLayer(0, 170)); + case 3 -> List.of(new NonDestroyedSkinClient.MessLayer(0, 255)); + case 4 -> List.of(new NonDestroyedSkinClient.MessLayer(0, 170), new NonDestroyedSkinClient.MessLayer(1, 85)); + case 5 -> List.of(new NonDestroyedSkinClient.MessLayer(0, 85), new NonDestroyedSkinClient.MessLayer(1, 170)); + case 6 -> List.of(new NonDestroyedSkinClient.MessLayer(1, 255)); + case 7 -> List.of(new NonDestroyedSkinClient.MessLayer(1, 170), new NonDestroyedSkinClient.MessLayer(2, 85)); + case 8 -> List.of(new NonDestroyedSkinClient.MessLayer(1, 85), new NonDestroyedSkinClient.MessLayer(2, 170)); + case 9, 10 -> List.of(new NonDestroyedSkinClient.MessLayer(2, 255)); + default -> List.of(); + }; + } + + private static void applyTintMask(NativeImage out, NativeImage mask, int tintRgb, int alpha) { + int overlayBase = 0xFF000000 | tintRgb & 16777215; + + for (int y = 0; y < out.getHeight(); y++) { + for (int x = 0; x < out.getWidth(); x++) { + int factor = maskFactor(sampleScaled(mask, x, y, out.getWidth(), out.getHeight())) * alpha / 255; + if (factor > 0) { + out.setColorArgb(x, y, blendArgb(out.getColorArgb(x, y), overlayBase, factor)); + } + } + } + } + + @Nullable + private static NativeImage readMessMask(MinecraftClient client, Identifier maskId) { + if (client != null && maskId != null) { + try { + Resource resource = (Resource)client.getResourceManager().getResource(maskId).orElse(null); + if (resource == null) { + return null; + } + + try (InputStream in = resource.getInputStream()) { + return NativeImage.read(in); + } + } catch (IOException | RuntimeException e) { + setupWarn("[NoN] Failed to read mess mask {}.", maskId, e); + return null; + } + } else { + return null; + } + } + + private static boolean isValidDestroyedSkinPng(byte[] bytes) { + if (bytes != null && bytes.length != 0 && bytes.length <= 1048576) { + try { + NativeImage image = NativeImage.read(bytes); + + boolean var3; + try { + int width = image.getWidth(); + var3 = width == image.getHeight() && width >= 64 && width <= 512 && width % 64 == 0; + } catch (Throwable var5) { + if (image != null) { + try { + image.close(); + } catch (Throwable var4) { + var5.addSuppressed(var4); + } + } + + throw var5; + } + + if (image != null) { + image.close(); + } + + return var3; + } catch (IOException | RuntimeException e) { + return false; + } + } else { + return false; + } + } + + private static boolean isValidMaskPng(byte[] bytes) { + if (bytes != null && bytes.length != 0 && bytes.length <= 65536) { + try { + NativeImage image = NativeImage.read(bytes); + + boolean var2; + try { + var2 = image.getWidth() == 64 && image.getHeight() == 64; + } catch (Throwable var5) { + if (image != null) { + try { + image.close(); + } catch (Throwable var4) { + var5.addSuppressed(var4); + } + } + + throw var5; + } + + if (image != null) { + image.close(); + } + + return var2; + } catch (IOException | RuntimeException e) { + return false; + } + } else { + return false; + } + } + + private static int[] getOriginalSkinColors(MinecraftClient client, UUID playerUuid, @Nullable Identifier baseSkin) { + if (baseSkin == null) { + return new int[]{15920872}; + } + + NonExternalSkinCompat.SkinContext externalSkin = effectiveExternalSkin(client, playerUuid, baseSkin); + NonDestroyedSkinClient.SkinColorKey key = new NonDestroyedSkinClient.SkinColorKey( + baseSkin, textureIdentity(client, baseSkin), externalSkinKey(externalSkin) + ); + int[] cached = ORIGINAL_SKIN_COLOR_CACHE.get(key); + if (cached != null && cached.length > 0) { + return cached; + } + + int[] colors = readOriginalSkinColors(client, baseSkin, externalSkin); + ORIGINAL_SKIN_COLOR_CACHE.put(key, colors); + return colors; + } + + private static int[] readOriginalSkinColors(MinecraftClient client, Identifier baseSkin, @Nullable NonExternalSkinCompat.SkinContext externalSkin) { + try { + Resource maskResource = (Resource)client.getResourceManager().getResource(RIPPED_FABRIC_COLOR_MASK).orElse(null); + if (maskResource == null) { + return new int[]{15920872}; + } + + NativeImage image = readTextureImage(client, baseSkin, externalSkin); + if (image == null) { + return new int[]{15920872}; + } + + NativeImage var5 = image; + + int[] result; + label144: { + int[] var23; + try (InputStream maskIn = maskResource.getInputStream()) { + label147: { + NativeImage mask = NativeImage.read(maskIn); + + label125: { + try { + int[] colors = new int[image.getWidth() * image.getHeight()]; + int count = 0; + + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int argb = image.getColorArgb(x, y); + if ((argb >>> 24 & 0xFF) >= 16) { + int maskArgb = sampleScaled(mask, x, y, image.getWidth(), image.getHeight()); + if (maskFactor(maskArgb) > 0) { + colors[count++] = argb & 16777215; + } + } + } + } + + if (count <= 0) { + result = new int[]{15920872}; + break label125; + } + + result = new int[count]; + System.arraycopy(colors, 0, result, 0, count); + var23 = result; + } catch (Throwable var17) { + if (mask != null) { + try { + mask.close(); + } catch (Throwable var16) { + var17.addSuppressed(var16); + } + } + + throw var17; + } + + if (mask != null) { + mask.close(); + } + break label147; + } + + if (mask != null) { + mask.close(); + } + break label144; + } + } catch (Throwable var19) { + if (var5 != null) { + try { + var5.close(); + } catch (Throwable var14) { + var19.addSuppressed(var14); + } + } + + throw var19; + } + + if (var5 != null) { + var5.close(); + } + + return var23; + } + + if (var5 != null) { + var5.close(); + } + + return result; + } catch (IOException | RuntimeException e) { + return new int[]{15920872}; + } + } + + private static int varyColor(int rgb, Random random) { + float factor = 0.85F + random.nextFloat() * 0.3F; + int r = Math.min(255, Math.max(0, Math.round((rgb >> 16 & 0xFF) * factor))); + int g = Math.min(255, Math.max(0, Math.round((rgb >> 8 & 0xFF) * factor))); + int b = Math.min(255, Math.max(0, Math.round((rgb & 0xFF) * factor))); + return r << 16 | g << 8 | b; + } + + private static void clearCachedTextures(UUID playerUuid) { + MinecraftClient client = MinecraftClient.getInstance(); + List texturesToDestroy = new ArrayList<>(); + TEXTURE_CACHE.entrySet().removeIf(entry -> { + if (!entry.getKey().playerUuid().equals(playerUuid)) { + return false; + } + + texturesToDestroy.add(entry.getValue()); + return true; + }); + destroyTextures(client, texturesToDestroy); + } + + private static void destroyTextures(@Nullable MinecraftClient client, List textureIds) { + if (client != null && textureIds != null && !textureIds.isEmpty()) { + List ids = List.copyOf(textureIds); + client.execute(() -> { + for (Identifier id : ids) { + client.getTextureManager().destroyTexture(id); + } + }); + } + } + + private static int sampleScaled(NativeImage source, int x, int y, int targetWidth, int targetHeight) { + int sourceX = Math.min(source.getWidth() - 1, Math.max(0, x * source.getWidth() / targetWidth)); + int sourceY = Math.min(source.getHeight() - 1, Math.max(0, y * source.getHeight() / targetHeight)); + return source.getColorArgb(sourceX, sourceY); + } + + private static void copyScaled(NativeImage source, NativeImage target) { + for (int y = 0; y < target.getHeight(); y++) { + for (int x = 0; x < target.getWidth(); x++) { + target.setColorArgb(x, y, sampleScaled(source, x, y, target.getWidth(), target.getHeight())); + } + } + } + + private static NativeImage scaledCopy(NativeImage source, int width, int height) { + NativeImage target = new NativeImage(Format.RGBA, width, height, false); + copyScaled(source, target); + return target; + } + + private static int clampStage(int stage) { + return Math.max(0, Math.min(4, stage)); + } + + private static int clampMess(int value) { + return Math.max(0, Math.min(10, value)); + } + + private static NonDestroyedSkinClient.TankMaskType resolveTankMaskType(@Nullable LivingEntity entity) { + if (entity instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask == 1) { + return NonDestroyedSkinClient.TankMaskType.A; + } + } + + return NonDestroyedSkinClient.TankMaskType.V; + } + + private static void setupWarn(String template, Object... args) { + NeedsOfNature.LOGGER.warn(template, args); + NeedsOfNatureClient.sendClientSetupWarning(NeedsOfNature.formatLogTemplate(template, args)); + } + + private static void markAutomaticSkinUnavailable(String template, Object... args) { + if (!automaticSkinUnavailableThisConnection) { + setupWarn(template, args); + } + + automaticSkinUnavailableThisConnection = true; + uploadRetryTicks = 0; + } + + private static final class ColorBucket { + long r; + long g; + long b; + int count; + + void add(int rgb, int weight) { + this.r += (long)(rgb >>> 16 & 0xFF) * weight; + this.g += (long)(rgb >>> 8 & 0xFF) * weight; + this.b += (long)(rgb & 0xFF) * weight; + this.count += weight; + } + + int averageR() { + return this.count <= 0 ? 0 : (int)(this.r / this.count); + } + + int averageG() { + return this.count <= 0 ? 0 : (int)(this.g / this.count); + } + + int averageB() { + return this.count <= 0 ? 0 : (int)(this.b / this.count); + } + } + + private record DefaultSkinCandidate(Identifier genderSpecific, boolean genderFound, Identifier generic, boolean genericFound) { + String logString() { + return this.genderSpecific + "=" + this.genderFound + ", " + this.generic + "=" + this.genericFound; + } + } + + private record DefaultSkinKey(String model, String name) { + } + + private record DefaultSkinMatch(NonDestroyedSkinClient.DefaultSkinKey key, double confidence) { + boolean accepted() { + return this.confidence >= 0.9; + } + } + + private record EffectiveBaseTexture(Identifier id, int textureIdentity, @Nullable NonExternalSkinCompat.SkinContext externalSkin) { + } + + private record MessLayer(int index, int alpha) { + } + + private record MessState(int vMess, int aMess, int mMess, int tintRgb, int liquidStored, int liquidCapacity) { + static final NonDestroyedSkinClient.MessState EMPTY = new NonDestroyedSkinClient.MessState(0, 0, 0, 16777215, 0, 0); + + boolean hasAnyMess() { + return this.vMess > 0 || this.aMess > 0 || this.mMess > 0; + } + + boolean hasTankOverlay() { + return this.liquidStored > 0 && this.liquidCapacity > 0; + } + + boolean hasAnyVisual() { + return this.hasAnyMess() || this.hasTankOverlay(); + } + + int tankAlpha() { + if (!this.hasTankOverlay()) { + return 0; + } + + float ratio = Math.max(0.0F, Math.min(1.0F, (float)this.liquidStored / this.liquidCapacity)); + return Math.max(3, Math.min(255, Math.round(3.0F + ratio * 252.0F))); + } + + String cacheKey() { + return NonDestroyedSkinClient.clampMess(this.vMess) + + "_" + + NonDestroyedSkinClient.clampMess(this.aMess) + + "_" + + NonDestroyedSkinClient.clampMess(this.mMess) + + "_" + + Math.max(0, this.liquidStored) + + "_" + + Math.max(0, this.liquidCapacity) + + "_" + + Integer.toHexString(this.tintRgb & 16777215); + } + } + + private static final class MissingDestroyedSkinFallbackException extends IOException { + private MissingDestroyedSkinFallbackException(String message) { + super(message); + } + } + + private record SkinColorKey(Identifier textureId, int textureIdentity, String externalSkinKey) { + } + + private enum TankMaskType { + V("v"), + A("a"); + + private final String id; + + TankMaskType(String id) { + this.id = id; + } + } + + private record TextureKey( + UUID playerUuid, + int stage, + @Nullable Identifier baseTexture, + int baseTextureIdentity, + String externalSkinKey, + NonDestroyedSkinClient.MessState messState, + NonDestroyedSkinClient.TankMaskType tankMaskType, + List accessoryOverlays + ) { + } + + private record TextureReadKey(Identifier textureId, int textureIdentity, String externalSkinKey) { + } +} diff --git a/src/main/java/com/nonid/client/NonEnergizedEffectTextures.java b/src/main/java/com/nonid/client/NonEnergizedEffectTextures.java new file mode 100644 index 0000000..b5673a7 --- /dev/null +++ b/src/main/java/com/nonid/client/NonEnergizedEffectTextures.java @@ -0,0 +1,39 @@ +package com.nonid.client; + +import com.nonid.effect.NonStatusEffects; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; + +public final class NonEnergizedEffectTextures { + private static final Identifier ENERGIZED_SPRITE_ID = Identifier.of("needsofnature", "mob_effect/energized"); + private static final Identifier ENERGIZED_STAGE_ONE_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/energized1.png"); + private static final Identifier ENERGIZED_STAGE_TWO_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/energized2.png"); + private static final Identifier ENERGIZED_STAGE_THREE_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/energized3.png"); + + private NonEnergizedEffectTextures() { + } + + public static boolean isEnergizedSprite(Identifier sprite) { + return ENERGIZED_SPRITE_ID.equals(sprite); + } + + public static Identifier resolveTexture(ClientPlayerEntity player) { + if (player == null) { + return ENERGIZED_STAGE_ONE_TEXTURE; + } else { + StatusEffectInstance effect = player.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGIZED)); + if (effect == null) { + return ENERGIZED_STAGE_ONE_TEXTURE; + } else { + int amplifier = Math.max(0, effect.getAmplifier()); + if (amplifier >= 2) { + return ENERGIZED_STAGE_THREE_TEXTURE; + } else { + return amplifier == 1 ? ENERGIZED_STAGE_TWO_TEXTURE : ENERGIZED_STAGE_ONE_TEXTURE; + } + } + } + } +} diff --git a/src/main/java/com/nonid/client/NonExternalSkinCompat.java b/src/main/java/com/nonid/client/NonExternalSkinCompat.java new file mode 100644 index 0000000..6e698d9 --- /dev/null +++ b/src/main/java/com/nonid/client/NonExternalSkinCompat.java @@ -0,0 +1,116 @@ +package com.nonid.client; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonExternalSkinCompat { + private NonExternalSkinCompat() { + } + + @Nullable + static NonExternalSkinCompat.SkinContext resolve(MinecraftClient client, UUID playerUuid, @Nullable Identifier expectedTexture) { + return client != null && playerUuid != null ? selectMatching(expectedTexture, candidates(client, playerUuid)) : null; + } + + @Nullable + static NonExternalSkinCompat.SkinContext resolveSelected(MinecraftClient client, UUID playerUuid) { + if (client != null && playerUuid != null) { + List candidates = candidates(client, playerUuid); + return candidates.isEmpty() ? null : candidates.getFirst(); + } else { + return null; + } + } + + private static List candidates(MinecraftClient client, UUID playerUuid) { + String playerName = playerName(client, playerUuid); + List candidates = new ArrayList<>(2); + NonSkinOverrideCompat.SkinSelection skinOverride = NonSkinOverrideCompat.getSelectedSkin(playerName, playerUuid); + if (skinOverride != null) { + candidates.add( + new NonExternalSkinCompat.SkinContext( + "skin_overrides", + skinOverride.textureId(), + skinOverride.selectionId(), + skinOverride.friendlyName(), + skinOverride.folderName(), + skinOverride.sourcePath(), + skinOverride.sourceFingerprint() + ) + ); + } + + NonQuickSkinCompat.SkinSelection quickSkin = NonQuickSkinCompat.getSelectedSkin(playerUuid); + if (quickSkin != null) { + candidates.add( + new NonExternalSkinCompat.SkinContext( + "quickskin", quickSkin.textureId(), quickSkin.skinId(), quickSkin.friendlyName(), quickSkin.folderName(), null, "skin=" + quickSkin.skinId() + ) + ); + } + + return candidates; + } + + @Nullable + static NonExternalSkinCompat.SkinContext selectMatching(@Nullable Identifier expectedTexture, List candidates) { + if (candidates != null && !candidates.isEmpty()) { + if (expectedTexture == null) { + return candidates.getFirst(); + } + + for (NonExternalSkinCompat.SkinContext candidate : candidates) { + if (expectedTexture.equals(candidate.textureId())) { + return candidate; + } + } + + return null; + } else { + return null; + } + } + + private static String playerName(MinecraftClient client, UUID playerUuid) { + if (client.player != null && playerUuid.equals(client.player.getUuid())) { + return client.player.getGameProfile().name(); + } + + if (client.world != null) { + PlayerEntity player = client.world.getPlayerByUuid(playerUuid); + if (player != null) { + return player.getGameProfile().name(); + } + } + + return ""; + } + + record SkinContext( + String providerId, + Identifier textureId, + String selectionId, + @Nullable String friendlyName, + @Nullable String folderName, + @Nullable Path sourcePath, + String sourceFingerprint + ) { + String cacheKey() { + return this.providerId + "\u0000" + this.textureId + "\u0000" + this.selectionId + "\u0000" + this.sourceFingerprint; + } + + String selectionKey() { + return this.providerId + "\u0000" + this.selectionId + "\u0000" + this.sourceFingerprint; + } + + String displayName() { + return this.friendlyName != null ? this.friendlyName : this.selectionId; + } + } +} diff --git a/src/main/java/com/nonid/client/NonFilledEffectTextures.java b/src/main/java/com/nonid/client/NonFilledEffectTextures.java new file mode 100644 index 0000000..729d7b8 --- /dev/null +++ b/src/main/java/com/nonid/client/NonFilledEffectTextures.java @@ -0,0 +1,39 @@ +package com.nonid.client; + +import com.nonid.effect.NonStatusEffects; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; + +public final class NonFilledEffectTextures { + private static final Identifier FILLED_SPRITE_ID = Identifier.of("needsofnature", "mob_effect/filled"); + private static final Identifier FILLED_STAGE_ONE_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/filled1.png"); + private static final Identifier FILLED_STAGE_TWO_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/filled2.png"); + private static final Identifier FILLED_STAGE_THREE_TEXTURE = Identifier.of("needsofnature", "textures/mob_effects/filled3.png"); + + private NonFilledEffectTextures() { + } + + public static boolean isFilledSprite(Identifier sprite) { + return FILLED_SPRITE_ID.equals(sprite); + } + + public static Identifier resolveTexture(ClientPlayerEntity player) { + if (player == null) { + return FILLED_STAGE_ONE_TEXTURE; + } else { + StatusEffectInstance effect = player.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FILLED)); + if (effect == null) { + return FILLED_STAGE_ONE_TEXTURE; + } else { + int amplifier = Math.max(0, effect.getAmplifier()); + if (amplifier >= 2) { + return FILLED_STAGE_THREE_TEXTURE; + } else { + return amplifier == 1 ? FILLED_STAGE_TWO_TEXTURE : FILLED_STAGE_ONE_TEXTURE; + } + } + } + } +} diff --git a/src/main/java/com/nonid/client/NonGenderSelectionScreen.java b/src/main/java/com/nonid/client/NonGenderSelectionScreen.java new file mode 100644 index 0000000..851e356 --- /dev/null +++ b/src/main/java/com/nonid/client/NonGenderSelectionScreen.java @@ -0,0 +1,122 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.network.SetPlayerGenderC2SPayload; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +public final class NonGenderSelectionScreen extends Screen { + private final int allowedMask; + private final int currentMask; + private final boolean permanent; + private int panelX; + private int panelY; + private int panelW; + private int panelH; + + public NonGenderSelectionScreen(int allowedMask, int currentMask, boolean permanent) { + super(Text.translatable("screen.needsofnature.gender_selection.title")); + this.allowedMask = (allowedMask & 7) == 0 ? 7 : allowedMask & 7; + this.currentMask = currentMask & 3; + this.permanent = permanent; + } + + protected void init() { + this.panelW = Math.min(340, Math.max(260, this.width - 40)); + this.panelH = 176; + this.panelX = (this.width - this.panelW) / 2; + this.panelY = Math.max(34, (this.height - this.panelH) / 2); + int buttonW = this.panelW - 56; + int buttonX = this.panelX + 28; + int y = this.panelY + 72; + this.addGenderButton(buttonX, y, buttonW, NonConfig.PlayerGenderSelection.MALE); + this.addGenderButton(buttonX, y + 26, buttonW, NonConfig.PlayerGenderSelection.FEMALE); + this.addGenderButton(buttonX, y + 52, buttonW, NonConfig.PlayerGenderSelection.BOTH); + } + + private void addGenderButton(int x, int y, int w, NonConfig.PlayerGenderSelection selection) { + ButtonWidget button = ButtonWidget.builder(this.genderText(selection), ignored -> this.choose(selection)).dimensions(x, y, w, 22).build(); + button.active = this.isAllowed(selection.mask()); + this.addDrawableChild(button); + } + + private void choose(NonConfig.PlayerGenderSelection selection) { + if (this.isAllowed(selection.mask())) { + if (ClientPlayNetworking.canSend(SetPlayerGenderC2SPayload.ID)) { + ClientPlayNetworking.send(new SetPlayerGenderC2SPayload(selection.mask(), true)); + } + + NonConfig config = NeedsOfNature.getLocalConfig(); + if (config != null) { + config.setPlayerGenderSelection(selection); + config.save(); + NonWildfireGenderSync.syncFromMask(config, selection.mask()); + } + + MinecraftClient.getInstance().setScreen(null); + } + } + + private boolean isAllowed(int mask) { + int bit = switch (mask & 3) { + case 1 -> 1; + case 2 -> 2; + case 3 -> 4; + default -> 0; + }; + return bit != 0 && (this.allowedMask & bit) != 0; + } + + private Text genderText(NonConfig.PlayerGenderSelection selection) { + Text base = switch (selection) { + case MALE -> Text.translatable("screen.needsofnature.gender_selection.option.male").copy().formatted(Formatting.AQUA); + case FEMALE -> Text.translatable("screen.needsofnature.gender_selection.option.female").copy().formatted(Formatting.LIGHT_PURPLE); + case BOTH -> Text.translatable("screen.needsofnature.gender_selection.option.both").copy().formatted(Formatting.GOLD); + }; + return (Text)(selection.mask() == this.currentMask ? Text.translatable("screen.needsofnature.gender_selection.current", new Object[]{base}) : base); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.drawPanel(context); + super.render(context, mouseX, mouseY, deltaTicks); + this.drawTitle(context); + } + + public void renderInGameBackground(DrawContext context) { + } + + private void drawTitle(DrawContext context) { + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.panelY + 18, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.translatable(this.permanent ? "screen.needsofnature.gender_selection.permanent" : "screen.needsofnature.gender_selection.changeable"), + this.width / 2, + this.panelY + 40, + this.permanent ? -10630 : -5708033 + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("screen.needsofnature.gender_selection.subtitle"), this.width / 2, this.panelY + 54, -5197648 + ); + } + + private void drawPanel(DrawContext context) { + context.fillGradient(0, 0, this.width, this.height, -871888629, -585427182); + context.fill(this.panelX - 2, this.panelY - 2, this.panelX + this.panelW + 2, this.panelY + this.panelH + 2, -1442840576); + context.fill(this.panelX, this.panelY, this.panelX + this.panelW, this.panelY + this.panelH, -300345324); + context.fill(this.panelX, this.panelY, this.panelX + this.panelW, this.panelY + 2, -12166); + context.fill(this.panelX, this.panelY + this.panelH - 2, this.panelX + this.panelW, this.panelY + this.panelH, -8760784); + context.fill(this.panelX, this.panelY, this.panelX + 2, this.panelY + this.panelH, -8760784); + context.fill(this.panelX + this.panelW - 2, this.panelY, this.panelX + this.panelW, this.panelY + this.panelH, -8760784); + context.fill(this.panelX + 18, this.panelY + 64, this.panelX + this.panelW - 18, this.panelY + 65, 1716207668); + context.fill(this.panelX + 18, this.panelY + this.panelH - 28, this.panelX + this.panelW - 18, this.panelY + this.panelH - 27, 1716207668); + } + + public void close() { + } +} diff --git a/src/main/java/com/nonid/client/NonHudOverlay.java b/src/main/java/com/nonid/client/NonHudOverlay.java new file mode 100644 index 0000000..c3f050a --- /dev/null +++ b/src/main/java/com/nonid/client/NonHudOverlay.java @@ -0,0 +1,1051 @@ +package com.nonid.client; + +import com.nonid.EnergyHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.effect.NonStatusEffects; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.network.AttackStateS2CPayload; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.texture.SpriteAtlasTexture; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Atlases; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import org.joml.Matrix3x2fStack; + +@Environment(EnvType.CLIENT) +public final class NonHudOverlay { + private static final Identifier ENERGY_BAR_BG = Identifier.of("needsofnature", "hud/progress_background"); + private static final Identifier ENERGY_BAR_FILL = Identifier.of("needsofnature", "hud/progress_full"); + private static final Identifier ATTACK_BAR_BG = Identifier.of("needsofnature", "hud/attack_background"); + private static final Identifier ATTACK_BAR_FILL = Identifier.of("needsofnature", "hud/attack_full"); + private static final Identifier ATTACK_PROMPT = Identifier.of("needsofnature", "hud/ad_mash"); + private static final Identifier LIQUID_TANK_BG = Identifier.of("needsofnature", "hud/liquid_background"); + private static final Identifier LIQUID_TANK_FILL = Identifier.of("needsofnature", "hud/liquid_full"); + private static final Identifier ENERGY_HEART = Identifier.of("needsofnature", "textures/particle/smallheart.png"); + private static final Identifier HUD_TEXTURE_SIZE_RELOADER_ID = NeedsOfNature.id("hud_texture_sizes"); + private static final int ENERGY_BAR_DEFAULT_W = 182; + private static final int ENERGY_BAR_DEFAULT_H = 5; + private static final int ENERGY_BAR_BOTTOM_MARGIN = 27; + private static final int ATTACK_BAR_DEFAULT_W = 81; + private static final int ATTACK_BAR_DEFAULT_H = 5; + private static final int ATTACK_BAR_DEFAULT_Y_OFFSET = -4; + private static final int ATTACK_PROMPT_DEFAULT_TEX_W = 68; + private static final int ATTACK_PROMPT_DEFAULT_TEX_H = 128; + private static final int ENERGY_HEART_DEFAULT_W = 7; + private static final int ENERGY_HEART_DEFAULT_H = 7; + private static final int LIQUID_DEFAULT_W = 8; + private static final float LOOP_PROGRESS_HIDDEN = -1.0F; + private static final int LIQUID_DEFAULT_H = 31; + private static final float ATTACK_ESCAPE_MIN_HEALTH_FACTOR = 0.3F; + private static final float ATTACK_PROGRESS_EASE_RATE = 18.0F; + private static final float ATTACK_PROGRESS_EASE_SNAP_EPSILON = 5.0E-4F; + private static final float ATTACK_PROGRESS_EASE_MAX_FRAME_SECONDS = 0.1F; + private static final long DEFEATED_EASE_DURATION_TICKS = 300L; + private static final float DEFEATED_MAX_STEP_MULTIPLIER = 4.0F; + private static boolean hideVanillaHud = false; + private static float loopProgress = 0.0F; + private static long lastUpdateTick = -1L; + private static UUID attackInstance = null; + private static float attackProgress = 0.0F; + private static float displayedAttackProgress = 0.0F; + private static NonHudOverlay.AttackKey lastAttackKey = NonHudOverlay.AttackKey.NONE; + private static long lastAttackDecayTick = -1L; + private static boolean attackInputClearPending = false; + private static boolean attackEscapable = true; + private static AttackStateS2CPayload.EscapeProfile attackEscapeProfile = AttackStateS2CPayload.EscapeProfile.NONE; + private static AttackStateS2CPayload.PvpRole attackPvpRole = AttackStateS2CPayload.PvpRole.NONE; + private static int liquidStored = 0; + private static int liquidCapacity = 200; + private static int liquidTintRgb = 15920063; + private static int runtimeAttackEscapeHits = -1; + private static boolean uiPreviewEnabled = false; + private static boolean uiPreviewOverrideActive = false; + private static int previewEnergyX = 0; + private static int previewEnergyY = 0; + private static int previewEnergyHeartX = 0; + private static int previewEnergyHeartY = 0; + private static int previewAttackX = 0; + private static int previewAttackY = 0; + private static int previewPromptX = 0; + private static int previewPromptY = 0; + private static int previewLiquidX = 0; + private static int previewLiquidY = 0; + private static boolean previewEnergyVisible = true; + private static boolean previewEnergyHeartVisible = true; + private static boolean previewAttackVisible = true; + private static boolean previewPromptVisible = true; + private static boolean previewLiquidVisible = true; + private static volatile NonHudOverlay.SpriteSize energyHeartSize = new NonHudOverlay.SpriteSize(7, 7); + + private NonHudOverlay() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES) + .registerReloader(HUD_TEXTURE_SIZE_RELOADER_ID, (SynchronousResourceReloader)NonHudOverlay::reloadDirectTextureSizes); + } + + public static void clientTick(MinecraftClient client) { + if (client != null) { + ClientPlayerEntity player = client.player; + if (player == null || client.world == null) { + clearAnimationHudState(); + liquidStored = 0; + liquidTintRgb = 15920063; + } else if (player.isSpectator()) { + clearAnimationHudState(); + } else { + boolean animActive = ClientAnimationRuntime.isActorPendingOrActive(player.getUuid()); + UUID activeInstance = ClientAnimationRuntime.findLatestActiveInstanceContaining(player.getUuid()); + if (!animActive) { + clearAnimationHudState(); + } else { + hideVanillaHud = true; + if (attackInstance != null && activeInstance != null && !activeInstance.equals(attackInstance) && !attackInputClearPending) { + resetAttackState(); + } + + long now = client.world.getTime(); + if (lastUpdateTick != now) { + lastUpdateTick = now; + loopProgress = computeLoopProgress(player.getUuid(), now); + tickAttackDecay(now); + } + } + } + } + } + + public static void onDisconnect(MinecraftClient client) { + clearAnimationHudState(); + liquidStored = 0; + liquidTintRgb = 15920063; + clearPlayerRuntimeSettings(); + } + + private static void clearAnimationHudState() { + hideVanillaHud = false; + loopProgress = 0.0F; + resetAttackState(); + } + + public static boolean shouldHideVanillaHud() { + return hideVanillaHud; + } + + public static boolean shouldRenderGameplayHud() { + MinecraftClient client = MinecraftClient.getInstance(); + return client != null && client.player != null && client.options != null && !client.options.hudHidden && !client.player.isSpectator(); + } + + public static void renderOverlay(DrawContext ctx) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && ctx != null) { + if (shouldRenderGameplayHud()) { + updateDisplayedAttackProgress(client); + NonHudOverlay.UiOffsets offsets = resolveUiOffsets(false); + NonHudOverlay.UiVisibility visibility = resolveUiVisibility(false); + NonHudOverlay.LayeredSprites energySprites = resolveLayeredSprites(ENERGY_BAR_BG, ENERGY_BAR_FILL, 182, 5); + NonHudOverlay.Rect baseEnergy = energyBarRect(ctx, energySprites, 0, 0); + NonHudOverlay.Rect energy = offsetRect(baseEnergy, offsets.energyX(), offsets.energyY()); + NonHudOverlay.LayeredSprites attackSprites = resolveLayeredSprites(ATTACK_BAR_BG, ATTACK_BAR_FILL, 81, 5); + NonHudOverlay.Rect baseAttack = attackBarRect(baseEnergy, attackSprites, 0, 0); + float displayedLoopProgress = loopProgress; + if (client.player != null && client.world != null) { + float tickProgress = client.getRenderTickCounter().getTickProgress(false); + displayedLoopProgress = computeLoopProgress(client.player.getUuid(), (float)client.world.getTime() + Math.max(0.0F, tickProgress)); + } + + float progress = displayedLoopProgress < 0.0F ? -1.0F : Math.max(0.0F, Math.min(1.0F, displayedLoopProgress)); + if (visibility.energy() && progress >= 0.0F) { + drawHorizontalBar(ctx, ENERGY_BAR_BG, ENERGY_BAR_FILL, energySprites, energy, progress); + } + + if (client.player != null && attackInstance != null && ClientAnimationRuntime.isActorPendingOrActive(client.player.getUuid())) { + UUID inst = ClientAnimationRuntime.findLatestActiveInstanceContaining(client.player.getUuid()); + if ((inst == null || inst.equals(attackInstance)) && attackEscapable) { + NonHudOverlay.Rect attack = attackBarDisplayRect(baseAttack, offsets); + if (visibility.attack()) { + drawHorizontalBar(ctx, ATTACK_BAR_BG, ATTACK_BAR_FILL, attackSprites, attack, displayedAttackProgress); + } + + if (visibility.prompt()) { + NonHudOverlay.AttackPromptInfo promptInfo = resolveAttackPromptInfo(); + NonHudOverlay.Rect prompt = attackPromptRect(ctx, baseAttack, promptInfo, offsets.promptX(), offsets.promptY()); + int v = lastAttackKey == NonHudOverlay.AttackKey.LEFT ? promptInfo.frameHeight() : 0; + drawAttackPrompt(ctx, promptInfo, prompt, v); + } + } + } + + renderLiquidOverlay(ctx); + } + } + } + + public static void renderLiquidOverlay(DrawContext ctx) { + if (shouldRenderGameplayHud()) { + renderLiquidOverlayInternal(ctx, false); + renderEnergyHeartOverlay(ctx, false); + } + } + + public static void renderUiPreview(DrawContext ctx) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && ctx != null) { + NonHudOverlay.UiOffsets offsets = resolveUiOffsets(true); + NonHudOverlay.UiVisibility visibility = resolveUiVisibility(true); + NonHudOverlay.LayeredSprites energySprites = resolveLayeredSprites(ENERGY_BAR_BG, ENERGY_BAR_FILL, 182, 5); + NonHudOverlay.Rect baseEnergy = energyBarRect(ctx, energySprites, 0, 0); + NonHudOverlay.Rect energy = offsetRect(baseEnergy, offsets.energyX(), offsets.energyY()); + NonHudOverlay.LayeredSprites attackSprites = resolveLayeredSprites(ATTACK_BAR_BG, ATTACK_BAR_FILL, 81, 5); + NonHudOverlay.Rect baseAttack = attackBarRect(baseEnergy, attackSprites, 0, 0); + if (visibility.energy()) { + drawHorizontalBar(ctx, ENERGY_BAR_BG, ENERGY_BAR_FILL, energySprites, energy, 0.6F); + } + + NonHudOverlay.Rect attack = attackBarDisplayRect(baseAttack, offsets); + if (visibility.attack()) { + drawHorizontalBar(ctx, ATTACK_BAR_BG, ATTACK_BAR_FILL, attackSprites, attack, 0.4F); + } + + if (visibility.prompt()) { + NonHudOverlay.AttackPromptInfo promptInfo = resolveAttackPromptInfo(); + NonHudOverlay.Rect prompt = attackPromptRect(ctx, baseAttack, promptInfo, offsets.promptX(), offsets.promptY()); + drawAttackPrompt(ctx, promptInfo, prompt, 0); + } + + renderLiquidOverlayInternal(ctx, true); + renderEnergyHeartOverlay(ctx, true); + } + } + + public static NonHudOverlay.UiPreviewBounds getUiPreviewBounds(DrawContext ctx) { + if (ctx == null) { + return NonHudOverlay.UiPreviewBounds.empty(); + } + + NonHudOverlay.UiOffsets offsets = resolveUiOffsets(true); + NonHudOverlay.LayeredSprites energySprites = resolveLayeredSprites(ENERGY_BAR_BG, ENERGY_BAR_FILL, 182, 5); + NonHudOverlay.Rect baseEnergy = energyBarRect(ctx, energySprites, 0, 0); + NonHudOverlay.Rect energy = offsetRect(baseEnergy, offsets.energyX(), offsets.energyY()); + NonHudOverlay.LayeredSprites attackSprites = resolveLayeredSprites(ATTACK_BAR_BG, ATTACK_BAR_FILL, 81, 5); + NonHudOverlay.Rect baseAttack = attackBarRect(baseEnergy, attackSprites, 0, 0); + NonHudOverlay.Rect attack = attackBarDisplayRect(baseAttack, offsets); + NonHudOverlay.AttackPromptInfo promptInfo = resolveAttackPromptInfo(); + NonHudOverlay.Rect prompt = attackPromptRect(ctx, baseAttack, promptInfo, offsets.promptX(), offsets.promptY()); + NonHudOverlay.LayeredSprites liquidSprites = resolveLiquidSprites(); + NonHudOverlay.Rect liquid = liquidTankRect(ctx, liquidSprites, offsets.liquidX(), offsets.liquidY()); + NonHudOverlay.Rect heart = energyHeartRect(ctx, true); + return new NonHudOverlay.UiPreviewBounds(energy, heart, attack, prompt, liquid); + } + + private static void renderLiquidOverlayInternal(DrawContext ctx, boolean preview) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && ctx != null) { + if (resolveUiVisibility(preview).liquid()) { + int capacity = liquidCapacity; + int stored = liquidStored; + if (preview) { + capacity = Math.max(1, capacity); + stored = Math.max(1, capacity / 2); + } + + NonHudOverlay.UiOffsets offsets = resolveUiOffsets(preview); + NonHudOverlay.LayeredSprites sprites = resolveLiquidSprites(); + NonHudOverlay.Rect tank = liquidTankRect(ctx, sprites, offsets.liquidX(), offsets.liquidY()); + if (stored > 0 && capacity > 0) { + float progress = Math.max(0.0F, Math.min(1.0F, (float)stored / capacity)); + NonHudOverlay.Rect background = bottomLeftAlignedRect(tank, sprites.background()); + NonHudOverlay.Rect fill = bottomLeftAlignedRect(tank, sprites.fill()); + int filled = Math.max(0, Math.min(fill.height(), Math.round(progress * fill.height()))); + drawGuiSprite(ctx, LIQUID_TANK_BG, background); + if (filled > 0) { + int fillY = fill.y() + fill.height() - filled; + ctx.enableScissor(fill.x(), fillY, fill.x() + fill.width(), fill.y() + fill.height()); + int fillColor = preview ? -1 : NonLiquidColors.toOpaqueArgb(liquidTintRgb); + drawGuiSprite(ctx, LIQUID_TANK_FILL, fill, fillColor); + ctx.disableScissor(); + } + } + } + } + } + + private static void renderEnergyHeartOverlay(DrawContext ctx, boolean preview) { + if (resolveUiVisibility(preview).energyHeart()) { + NonHudOverlay.Rect rect = energyHeartRect(ctx, preview); + renderEnergyHeart(ctx, rect, preview); + } + } + + private static NonHudOverlay.Rect energyHeartRect(DrawContext ctx, boolean preview) { + NonHudOverlay.LayeredSprites liquidSprites = resolveLiquidSprites(); + NonHudOverlay.Rect tank = liquidTankRect(ctx, liquidSprites, 0, 0); + NonHudOverlay.UiOffsets offsets = resolveUiOffsets(preview); + NonHudOverlay.SpriteSize heart = energyHeartSize; + int centerX = tank.x() + tank.width() / 2 + offsets.energyHeartX(); + int x = centerX - heart.width() / 2; + int y = tank.y() - 1 - heart.height() + offsets.energyHeartY(); + return new NonHudOverlay.Rect(x, y, heart.width(), heart.height()); + } + + private static NonHudOverlay.Rect liquidTankRect(DrawContext ctx, NonHudOverlay.LayeredSprites sprites, int offsetX, int offsetY) { + int heartsX = ctx.getScaledWindowWidth() / 2 - 91; + int heartsY = ctx.getScaledWindowHeight() - 39; + int x = heartsX + 192 - 8 + offsetX; + int bottom = heartsY + 10 + 29 + offsetY; + return new NonHudOverlay.Rect(x, bottom - sprites.height(), sprites.width(), sprites.height()); + } + + private static void renderEnergyHeart(DrawContext ctx, NonHudOverlay.Rect rect, boolean preview) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && ctx != null) { + int energizedLevel = preview ? 2 : getEnergizedLevel(client.player); + if (energizedLevel > 0) { + double pulse = computeHeartPulse(energizedLevel); + + float scale = switch (energizedLevel) { + case 1 -> 1.0F + (float)pulse * 0.35F; + case 2 -> 1.0F + (float)pulse * 0.45F; + default -> 1.0F + (float)pulse * 0.85F; + }; + int centerX = rect.x() + rect.width() / 2; + int centerY = rect.y() + rect.height() / 2; + + int alpha = switch (energizedLevel) { + case 1 -> 150 + (int)Math.round(pulse * 70.0); + case 2 -> 185 + (int)Math.round(pulse * 65.0); + default -> 200 + (int)Math.round(pulse * 55.0); + }; + int color = Math.max(0, Math.min(255, alpha)) << 24 | 16777215; + Matrix3x2fStack matrices = ctx.getMatrices(); + matrices.pushMatrix(); + matrices.translate(centerX, centerY); + matrices.scale(scale, scale); + matrices.translate(-centerX, -centerY); + ctx.drawTexture( + RenderPipelines.GUI_TEXTURED, ENERGY_HEART, rect.x(), rect.y(), 0.0F, 0.0F, rect.width(), rect.height(), rect.width(), rect.height(), color + ); + matrices.popMatrix(); + } + } + } + + public static int getEnergizedLevel(ClientPlayerEntity player) { + if (player == null) { + return 0; + } + + for (StatusEffectInstance effect : player.getStatusEffects()) { + if (effect.getEffectType().value() == NonStatusEffects.ENERGIZED) { + return Math.max(1, Math.min(3, effect.getAmplifier() + 1)); + } + } + + return 0; + } + + public static double computeHeartPulse(int energizedLevel) { + return computeHeartPulse(energizedLevel, 0L); + } + + public static double computeHeartPulse(int energizedLevel, long timeOffsetMs) { + double periodMs = switch (energizedLevel) { + case 1 -> 900.0; + case 2 -> 450.0; + default -> 225.0; + }; + + double attackMs = switch (energizedLevel) { + case 1 -> 120.0; + case 2 -> 55.0; + default -> 30.0; + }; + + double decayMs = switch (energizedLevel) { + case 1 -> 200.0; + case 2 -> 150.0; + default -> 100.0; + }; + double elapsedMs = Math.floorMod(System.currentTimeMillis() + Math.max(0L, timeOffsetMs), (long)periodMs); + if (elapsedMs >= attackMs + decayMs) { + return 0.0; + } else if (elapsedMs < attackMs) { + double attack = elapsedMs / attackMs; + return attack * attack; + } else { + double decay = (elapsedMs - attackMs) / decayMs; + double remaining = Math.max(0.0, 1.0 - decay); + return remaining * remaining; + } + } + + private static void drawHorizontalBar( + DrawContext ctx, Identifier backgroundId, Identifier fillId, NonHudOverlay.LayeredSprites sprites, NonHudOverlay.Rect bounds, float progress + ) { + float clamped = Math.max(0.0F, Math.min(1.0F, progress)); + NonHudOverlay.Rect background = bottomLeftAlignedRect(bounds, sprites.background()); + NonHudOverlay.Rect fill = bottomLeftAlignedRect(bounds, sprites.fill()); + int filled = Math.max(0, Math.min(fill.width(), Math.round(clamped * fill.width()))); + drawGuiSprite(ctx, backgroundId, background); + if (filled > 0) { + ctx.enableScissor(fill.x(), fill.y(), fill.x() + filled, fill.y() + fill.height()); + drawGuiSprite(ctx, fillId, fill); + ctx.disableScissor(); + } + } + + private static void drawAttackPrompt(DrawContext ctx, NonHudOverlay.AttackPromptInfo promptInfo, NonHudOverlay.Rect bounds, int v) { + ctx.drawGuiTexture( + RenderPipelines.GUI_TEXTURED, + ATTACK_PROMPT, + promptInfo.textureWidth(), + promptInfo.textureHeight(), + 0, + v, + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height() + ); + } + + private static void drawGuiSprite(DrawContext ctx, Identifier id, NonHudOverlay.Rect bounds) { + ctx.drawGuiTexture(RenderPipelines.GUI_TEXTURED, id, bounds.x(), bounds.y(), bounds.width(), bounds.height()); + } + + private static void drawGuiSprite(DrawContext ctx, Identifier id, NonHudOverlay.Rect bounds, int color) { + ctx.drawGuiTexture(RenderPipelines.GUI_TEXTURED, id, bounds.x(), bounds.y(), bounds.width(), bounds.height(), color); + } + + private static NonHudOverlay.Rect energyBarRect(DrawContext ctx, NonHudOverlay.LayeredSprites sprites, int offsetX, int offsetY) { + int x = (ctx.getScaledWindowWidth() - sprites.width()) / 2 + offsetX; + int bottom = ctx.getScaledWindowHeight() - 27 + offsetY; + return new NonHudOverlay.Rect(x, bottom - sprites.height(), sprites.width(), sprites.height()); + } + + private static NonHudOverlay.Rect attackBarRect(NonHudOverlay.Rect baseEnergy, NonHudOverlay.LayeredSprites sprites, int offsetX, int offsetY) { + int x = baseEnergy.x() + (baseEnergy.width() - sprites.width()) / 2 + offsetX; + int y = baseEnergy.y() - 8 - sprites.height() + offsetY; + return new NonHudOverlay.Rect(x, y, sprites.width(), sprites.height()); + } + + private static NonHudOverlay.Rect attackBarDisplayRect(NonHudOverlay.Rect baseAttack, NonHudOverlay.UiOffsets offsets) { + return offsetRect(baseAttack, offsets.attackX(), offsets.attackY() + -4); + } + + private static NonHudOverlay.Rect attackPromptRect( + DrawContext ctx, NonHudOverlay.Rect baseAttack, NonHudOverlay.AttackPromptInfo promptInfo, int offsetX, int offsetY + ) { + int frameW = Math.max(1, promptInfo.frameWidth()); + int frameH = Math.max(1, promptInfo.frameHeight()); + int x = (ctx.getScaledWindowWidth() - frameW) / 2 + offsetX; + int y = Math.max(4, baseAttack.y() - frameH - 6 + offsetY); + return new NonHudOverlay.Rect(x, y, frameW, frameH); + } + + private static NonHudOverlay.Rect bottomLeftAlignedRect(NonHudOverlay.Rect bounds, NonHudOverlay.SpriteSize size) { + return new NonHudOverlay.Rect(bounds.x(), bounds.y() + bounds.height() - size.height(), size.width(), size.height()); + } + + private static NonHudOverlay.Rect offsetRect(NonHudOverlay.Rect rect, int offsetX, int offsetY) { + return new NonHudOverlay.Rect(rect.x() + offsetX, rect.y() + offsetY, rect.width(), rect.height()); + } + + public static double getCurrentAnimationStageProgress(UUID playerUuid) { + MinecraftClient client = MinecraftClient.getInstance(); + if (playerUuid != null && client != null && client.world != null) { + float progress = computeLoopProgress(playerUuid, client.world.getTime()); + return progress == -1.0F ? Double.NaN : clampProgress(progress); + } else { + return Double.NaN; + } + } + + private static float computeLoopProgress(UUID playerUuid, double nowTick) { + UUID inst = ClientAnimationRuntime.findLatestActiveInstanceContaining(playerUuid); + if (inst == null) { + return 0.0F; + } else { + long stageStartTick = ClientAnimationRuntime.findStageStartTick(inst); + long stageEndTick = ClientAnimationRuntime.findStageEndTick(inst); + AnimationStageInfo currentStage = ClientAnimationRuntime.findCurrentStage(inst); + if (stageStartTick < 0L || currentStage == null) { + return 0.0F; + } else { + return stageEndTick <= stageStartTick ? -1.0F : clampProgress((nowTick - stageStartTick) / (stageEndTick - stageStartTick)); + } + } + } + + private static float clampProgress(double progress) { + return (float)Math.max(0.0, Math.min(1.0, progress)); + } + + private static NonHudOverlay.AttackPromptInfo resolveAttackPromptInfo() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return new NonHudOverlay.AttackPromptInfo(68, 128); + } + + SpriteAtlasTexture atlas = client.getAtlasManager().getAtlasTexture(Atlases.GUI); + Sprite sprite = atlas.getSprite(ATTACK_PROMPT); + if (sprite == null) { + return new NonHudOverlay.AttackPromptInfo(68, 128); + } + + int texW = sprite.getContents().getWidth(); + int texH = sprite.getContents().getHeight(); + return texW > 0 && texH > 0 ? new NonHudOverlay.AttackPromptInfo(texW, texH) : new NonHudOverlay.AttackPromptInfo(68, 128); + } + + private static NonHudOverlay.LayeredSprites resolveLiquidSprites() { + return resolveLayeredSprites(LIQUID_TANK_BG, LIQUID_TANK_FILL, 8, 31); + } + + private static NonHudOverlay.LayeredSprites resolveLayeredSprites(Identifier background, Identifier fill, int fallbackW, int fallbackH) { + return new NonHudOverlay.LayeredSprites(resolveSpriteSize(background, fallbackW, fallbackH), resolveSpriteSize(fill, fallbackW, fallbackH)); + } + + private static NonHudOverlay.SpriteSize resolveSpriteSize(Identifier id, int fallbackW, int fallbackH) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + + SpriteAtlasTexture atlas = client.getAtlasManager().getAtlasTexture(Atlases.GUI); + Sprite sprite = atlas.getSprite(id); + if (sprite == null) { + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + + int texW = sprite.getContents().getWidth(); + int texH = sprite.getContents().getHeight(); + return texW > 0 && texH > 0 ? new NonHudOverlay.SpriteSize(texW, texH) : new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + + private static void reloadDirectTextureSizes(ResourceManager manager) { + energyHeartSize = readDirectTextureSize(manager, ENERGY_HEART, 7, 7); + } + + private static NonHudOverlay.SpriteSize readDirectTextureSize(ResourceManager manager, Identifier id, int fallbackW, int fallbackH) { + if (manager == null) { + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + + try { + Resource resource = (Resource)manager.getResource(id).orElse(null); + if (resource == null) { + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + + try (InputStream input = resource.getInputStream()) { + NativeImage image = NativeImage.read(input); + + label106: { + NonHudOverlay.SpriteSize var9; + try { + int width = image.getWidth(); + int height = image.getHeight(); + if (width <= 0 || height <= 0) { + break label106; + } + + var9 = new NonHudOverlay.SpriteSize(width, height); + } catch (Throwable var12) { + if (image != null) { + try { + image.close(); + } catch (Throwable var11) { + var12.addSuppressed(var11); + } + } + + throw var12; + } + + if (image != null) { + image.close(); + } + + return var9; + } + + if (image != null) { + image.close(); + } + + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + } catch (IOException | RuntimeException var14) { + return new NonHudOverlay.SpriteSize(fallbackW, fallbackH); + } + } + + public static void setRuntimeAttackEscapeHits(int attackEscapeHits) { + runtimeAttackEscapeHits = Math.max(1, Math.min(50, attackEscapeHits)); + } + + public static int getRuntimeLoopSeconds() { + return resolveRuntimeLoopSeconds(); + } + + public static int getRuntimePeakLoopSeconds() { + return resolveRuntimePeakLoopSeconds(); + } + + public static int getRuntimeAttackEscapeHits() { + return resolveRuntimeAttackEscapeHits(); + } + + public static double getRuntimeAttackDecayPerSecond() { + return resolveRuntimeAttackDecayPerSecond(); + } + + public static int getRuntimeAttackEscapeDamageDifficultyPercent() { + return resolveRuntimeAttackEscapeDamageDifficultyPercent(); + } + + public static boolean getRuntimeAttackCreativePlayers() { + NonConfig config = NeedsOfNature.getConfig(); + return config != null && config.canAttackCreativePlayers(); + } + + private static int resolveRuntimeLoopSeconds() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 15 : config.getLoopProgressSeconds(); + } + + private static int resolveRuntimePeakLoopSeconds() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 7 : config.getPeakLoopProgressSeconds(); + } + + private static int resolveRuntimeAttackEscapeHits() { + if (runtimeAttackEscapeHits >= 0) { + return Math.max(1, Math.min(50, runtimeAttackEscapeHits)); + } + + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 12 : config.getAttackEscapeHits(); + } + + private static double resolveRuntimeAttackDecayPerSecond() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 0.2 : config.getAttackDecayPerSecond(); + } + + private static int resolveRuntimeAttackEscapeDamageDifficultyPercent() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 100 : config.getAttackEscapeDamageDifficultyPercent(); + } + + private static void clearPlayerRuntimeSettings() { + runtimeAttackEscapeHits = -1; + } + + public static void setLiquidState(int stored, int capacity, int tintRgb) { + liquidStored = Math.max(0, stored); + liquidCapacity = Math.max(1, capacity); + liquidTintRgb = tintRgb & 16777215; + if (liquidStored > liquidCapacity) { + liquidStored = liquidCapacity; + } + } + + public static float getLiquidFillRatio() { + if (liquidCapacity <= 0) { + return 0.0F; + } + + float ratio = (float)liquidStored / liquidCapacity; + return Math.max(0.0F, Math.min(1.0F, ratio)); + } + + public static int getLiquidTintRgb() { + return liquidTintRgb; + } + + public static void setEscapeControlState( + UUID instanceId, + boolean attack, + boolean escapable, + AttackStateS2CPayload.EscapeProfile escapeProfile, + AttackStateS2CPayload.PvpRole pvpRole, + float progress + ) { + if (instanceId != null) { + if (attack) { + if (!instanceId.equals(attackInstance)) { + resetAttackState(); + attackInstance = instanceId; + attackInputClearPending = true; + } + + attackEscapable = escapable; + attackEscapeProfile = sanitizeEscapeProfile(escapeProfile, attack); + attackPvpRole = pvpRole == null ? AttackStateS2CPayload.PvpRole.NONE : pvpRole; + if (attackPvpRole != AttackStateS2CPayload.PvpRole.NONE) { + attackProgress = MathHelper.clamp(progress, 0.0F, 1.0F); + } + } else if (instanceId.equals(attackInstance)) { + resetAttackState(); + } + } + } + + private static AttackStateS2CPayload.EscapeProfile sanitizeEscapeProfile(AttackStateS2CPayload.EscapeProfile profile, boolean attack) { + if (!attack) { + return AttackStateS2CPayload.EscapeProfile.NONE; + } else { + return profile != null && profile != AttackStateS2CPayload.EscapeProfile.NONE ? profile : AttackStateS2CPayload.EscapeProfile.NORMAL; + } + } + + public static boolean isAttackInstance(UUID instanceId) { + return instanceId != null && instanceId.equals(attackInstance); + } + + public static void recordAttackInput(UUID instanceId, NonHudOverlay.AttackKey key) { + if (isAttackInstance(instanceId)) { + if (key != NonHudOverlay.AttackKey.NONE) { + if (attackEscapable) { + if (lastAttackKey != key) { + if (attackPvpRole == AttackStateS2CPayload.PvpRole.NONE) { + float step = resolveAttackProgressStep(); + attackProgress = Math.min(1.0F, attackProgress + step); + } + + lastAttackKey = key; + } + } + } + } + } + + public static boolean isServerControlledAttack() { + return attackPvpRole != AttackStateS2CPayload.PvpRole.NONE; + } + + public static boolean isAttackProgressComplete() { + return !isServerControlledAttack() && attackProgress >= 1.0F; + } + + public static void resetAttackProgress() { + attackProgress = 0.0F; + displayedAttackProgress = 0.0F; + lastAttackKey = NonHudOverlay.AttackKey.NONE; + lastAttackDecayTick = -1L; + } + + private static void updateDisplayedAttackProgress(MinecraftClient client) { + if (attackInstance == null) { + displayedAttackProgress = attackProgress; + } else { + float difference = attackProgress - displayedAttackProgress; + if (Math.abs(difference) <= 5.0E-4F) { + displayedAttackProgress = attackProgress; + } else { + float frameSeconds = MathHelper.clamp(client.getRenderTickCounter().getDynamicDeltaTicks() / 20.0F, 0.0F, 0.1F); + float alpha = 1.0F - (float)Math.exp(-18.0F * frameSeconds); + displayedAttackProgress = MathHelper.clamp(MathHelper.lerp(alpha, displayedAttackProgress, attackProgress), 0.0F, 1.0F); + } + } + } + + private static void resetAttackState() { + attackInstance = null; + resetAttackProgress(); + attackInputClearPending = false; + attackEscapable = true; + attackEscapeProfile = AttackStateS2CPayload.EscapeProfile.NONE; + attackPvpRole = AttackStateS2CPayload.PvpRole.NONE; + } + + public static void setUiPreviewEnabled(boolean enabled) { + uiPreviewEnabled = enabled; + if (!enabled) { + uiPreviewOverrideActive = false; + } + } + + public static boolean isUiPreviewEnabled() { + return uiPreviewEnabled; + } + + public static void setUiPreviewOffsets( + int energyX, + int energyY, + int attackX, + int attackY, + int promptX, + int promptY, + int liquidX, + int liquidY, + int energyHeartX, + int energyHeartY, + boolean energyVisible, + boolean energyHeartVisible, + boolean attackVisible, + boolean promptVisible, + boolean liquidVisible + ) { + uiPreviewOverrideActive = true; + previewEnergyX = energyX; + previewEnergyY = energyY; + previewAttackX = attackX; + previewAttackY = attackY; + previewPromptX = promptX; + previewPromptY = promptY; + previewLiquidX = liquidX; + previewLiquidY = liquidY; + previewEnergyHeartX = energyHeartX; + previewEnergyHeartY = energyHeartY; + previewEnergyVisible = energyVisible; + previewEnergyHeartVisible = energyHeartVisible; + previewAttackVisible = attackVisible; + previewPromptVisible = promptVisible; + previewLiquidVisible = liquidVisible; + } + + private static NonHudOverlay.UiOffsets resolveUiOffsets(boolean preview) { + NonConfig config = NeedsOfNature.getConfig(); + int energyX = config == null ? 0 : config.getUiEnergyOffsetX(); + int energyY = config == null ? 0 : config.getUiEnergyOffsetY(); + int energyHeartX = config == null ? 0 : config.getUiEnergyHeartOffsetX(); + int energyHeartY = config == null ? 0 : config.getUiEnergyHeartOffsetY(); + int attackX = config == null ? 0 : config.getUiAttackOffsetX(); + int attackY = config == null ? 0 : config.getUiAttackOffsetY(); + int promptX = config == null ? 0 : config.getUiPromptOffsetX(); + int promptY = config == null ? 0 : config.getUiPromptOffsetY(); + int liquidX = config == null ? 0 : config.getUiLiquidOffsetX(); + int liquidY = config == null ? 0 : config.getUiLiquidOffsetY(); + return preview && uiPreviewOverrideActive + ? new NonHudOverlay.UiOffsets( + previewEnergyX, + previewEnergyY, + previewEnergyHeartX, + previewEnergyHeartY, + previewAttackX, + previewAttackY, + previewPromptX, + previewPromptY, + previewLiquidX, + previewLiquidY + ) + : new NonHudOverlay.UiOffsets(energyX, energyY, energyHeartX, energyHeartY, attackX, attackY, promptX, promptY, liquidX, liquidY); + } + + private static NonHudOverlay.UiVisibility resolveUiVisibility(boolean preview) { + NonConfig config = NeedsOfNature.getConfig(); + boolean energy = config == null || config.isUiEnergyVisible(); + boolean energyHeart = config == null || config.isUiEnergyHeartVisible(); + boolean attack = config == null || config.isUiAttackVisible(); + boolean prompt = config == null || config.isUiPromptVisible(); + boolean liquid = config == null || config.isUiLiquidVisible(); + return preview && uiPreviewOverrideActive + ? new NonHudOverlay.UiVisibility(previewEnergyVisible, previewEnergyHeartVisible, previewAttackVisible, previewPromptVisible, previewLiquidVisible) + : new NonHudOverlay.UiVisibility(energy, energyHeart, attack, prompt, liquid); + } + + public static boolean consumeAttackInputClear() { + if (!attackInputClearPending) { + return false; + } + + attackInputClearPending = false; + return true; + } + + private static float resolveAttackProgressStep() { + int hits = resolveRuntimeAttackEscapeHits(); + if (hits <= 0) { + return 1.0F; + } + + float base = 1.0F / hits; + float healthFactor = resolveAttackHealthFactor(); + if (healthFactor <= 0.0F) { + return 0.0F; + } + + float energyFactor = resolveAttackEnergyFactor(); + if (energyFactor <= 0.0F) { + return 0.0F; + } + + float rngScale = healthFactor >= 0.999F ? 1.0F : randomAttackScale(); + float defeatedEase = resolveDefeatedEaseProgress(); + if (defeatedEase <= 0.0F) { + return base * healthFactor * rngScale * energyFactor; + } + + float adjustedHealth = MathHelper.lerp(defeatedEase, healthFactor, 1.0F); + float adjustedEnergy = MathHelper.lerp(defeatedEase, energyFactor, 1.0F); + float adjustedRng = MathHelper.lerp(defeatedEase, rngScale, 1.0F); + float multiplier = MathHelper.lerp(defeatedEase, 1.0F, 4.0F); + return base * adjustedHealth * adjustedRng * adjustedEnergy * multiplier; + } + + private static float resolveDefeatedEaseProgress() { + if (attackInstance == null) { + return 0.0F; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.player == null || client.world == null) { + return 0.0F; + } + + if (attackEscapeProfile != AttackStateS2CPayload.EscapeProfile.DEFEATED) { + return 0.0F; + } + + Long startTick = ClientAnimationRuntime.findStartTickForActor(client.player.getUuid()); + if (startTick == null) { + return 0.0F; + } + + long elapsed = Math.max(0L, client.world.getTime() - startTick); + return MathHelper.clamp((float)elapsed / 300.0F, 0.0F, 1.0F); + } + + private static float resolveAttackHealthFactor() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + float health = client.player.getHealth(); + float maxHealth = client.player.getMaxHealth(); + float minHealth = 0.5F; + if (maxHealth <= minHealth) { + return 0.3F; + } + + float rawFactor = (health - minHealth) / (maxHealth - minHealth); + rawFactor = MathHelper.clamp(rawFactor, 0.0F, 1.0F); + float impact = resolveRuntimeAttackEscapeDamageDifficultyPercent() / 100.0F; + float factor = 1.0F - (1.0F - rawFactor) * impact; + return MathHelper.clamp(factor, 0.3F, 1.0F); + } else { + return 1.0F; + } + } + + private static float resolveAttackEnergyFactor() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player instanceof EnergyHolder holder) { + int var6 = Math.max(1, holder.getMaxEnergy()); + float energy = Math.max(0.0F, Math.min(var6, holder.getEnergy())); + if (energy <= 100.0F) { + float t = energy / 100.0F; + return 1.0F - 0.1F * t; + } else { + float t = Math.min(1.0F, (energy - 100.0F) / 100.0F); + float factor = 0.9F - 0.82F * t * t; + return Math.max(0.08F, factor); + } + } else { + return 1.0F; + } + } + + private static float randomAttackScale() { + return 0.5F + ThreadLocalRandom.current().nextFloat(); + } + + private static void tickAttackDecay(long nowTick) { + if (attackInstance == null) { + lastAttackDecayTick = nowTick; + } else if (isServerControlledAttack()) { + lastAttackDecayTick = nowTick; + } else if (lastAttackDecayTick < 0L) { + lastAttackDecayTick = nowTick; + } else { + long elapsedTicks = nowTick - lastAttackDecayTick; + if (elapsedTicks > 0L) { + lastAttackDecayTick = nowTick; + double decayPerSecond = resolveRuntimeAttackDecayPerSecond(); + if (!(decayPerSecond <= 0.0)) { + float decayPerTick = (float)(decayPerSecond / 20.0); + attackProgress = Math.max(0.0F, attackProgress - decayPerTick * (float)elapsedTicks); + } + } + } + } + + public enum AttackKey { + NONE, + LEFT, + RIGHT; + } + + private record AttackPromptInfo(int textureWidth, int textureHeight) { + int frameWidth() { + return this.textureWidth; + } + + int frameHeight() { + return this.textureHeight / 2; + } + } + + private record LayeredSprites(NonHudOverlay.SpriteSize background, NonHudOverlay.SpriteSize fill) { + int width() { + return Math.max(this.background.width(), this.fill.width()); + } + + int height() { + return Math.max(this.background.height(), this.fill.height()); + } + } + + public record Rect(int x, int y, int width, int height) { + public boolean contains(double mouseX, double mouseY) { + return mouseX >= this.x && mouseX < this.x + this.width && mouseY >= this.y && mouseY < this.y + this.height; + } + + public NonHudOverlay.Rect padded(int padding) { + int safe = Math.max(0, padding); + return new NonHudOverlay.Rect(this.x - safe, this.y - safe, this.width + safe * 2, this.height + safe * 2); + } + } + + private record SpriteSize(int width, int height) { + } + + private record UiOffsets( + int energyX, int energyY, int energyHeartX, int energyHeartY, int attackX, int attackY, int promptX, int promptY, int liquidX, int liquidY + ) { + } + + public record UiPreviewBounds( + NonHudOverlay.Rect energy, NonHudOverlay.Rect energyHeart, NonHudOverlay.Rect attack, NonHudOverlay.Rect prompt, NonHudOverlay.Rect liquid + ) { + public static NonHudOverlay.UiPreviewBounds empty() { + NonHudOverlay.Rect empty = new NonHudOverlay.Rect(0, 0, 0, 0); + return new NonHudOverlay.UiPreviewBounds(empty, empty, empty, empty, empty); + } + } + + private record UiVisibility(boolean energy, boolean energyHeart, boolean attack, boolean prompt, boolean liquid) { + } +} diff --git a/src/main/java/com/nonid/client/NonIconButtonTextures.java b/src/main/java/com/nonid/client/NonIconButtonTextures.java new file mode 100644 index 0000000..c379b58 --- /dev/null +++ b/src/main/java/com/nonid/client/NonIconButtonTextures.java @@ -0,0 +1,14 @@ +package com.nonid.client; + +import net.minecraft.util.Identifier; + +public final class NonIconButtonTextures { + public static final Identifier SKIN_REPAIR = Identifier.of("needsofnature", "textures/gui/skin_repair.png"); + public static final Identifier PREGNANCY_CYCLE_SAFE = Identifier.of("needsofnature", "textures/gui/pregnancy_cycle_safe.png"); + public static final Identifier PREGNANCY_CYCLE_NORMAL = Identifier.of("needsofnature", "textures/gui/pregnancy_cycle_normal.png"); + public static final Identifier PREGNANCY_CYCLE_RISKY = Identifier.of("needsofnature", "textures/gui/pregnancy_cycle_risky.png"); + public static final Identifier PREGNANCY_CYCLE_OVULATION = Identifier.of("needsofnature", "textures/gui/pregnancy_cycle_ovulation.png"); + + private NonIconButtonTextures() { + } +} diff --git a/src/main/java/com/nonid/client/NonInventoryScreenHooks.java b/src/main/java/com/nonid/client/NonInventoryScreenHooks.java new file mode 100644 index 0000000..11f6a7b --- /dev/null +++ b/src/main/java/com/nonid/client/NonInventoryScreenHooks.java @@ -0,0 +1,270 @@ +package com.nonid.client; + +import com.mojang.blaze3d.textures.GpuTextureView; +import com.nonid.DestroyedSkinHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonSkinRepairBlocks; +import com.nonid.PregnancyCyclePhase; +import com.nonid.mixin.client.HandledScreenAccessor; +import com.nonid.network.ManualDamageDestroyedSkinC2SPayload; +import com.nonid.network.RepairDestroyedSkinC2SPayload; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AfterInit; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.BeforeRender; +import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.AllowMouseClick; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.ingame.CreativeInventoryScreen; +import net.minecraft.client.gui.screen.ingame.HandledScreen; +import net.minecraft.client.gui.screen.ingame.InventoryScreen; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.sound.SoundManager; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public final class NonInventoryScreenHooks { + private static final int SURVIVAL_ICON_SIZE = 14; + private static final int SURVIVAL_BUTTON_SIZE = 18; + private static final int CREATIVE_ICON_SIZE = 12; + private static final int CREATIVE_BUTTON_SIZE = 16; + private static final int CYCLE_ICON_OFFSET_X = -2; + private static final int CYCLE_ICON_OFFSET_Y = -1; + private static final int SURVIVAL_CYCLE_X = 27; + private static final int SURVIVAL_REPAIR_X = 58; + private static final int SURVIVAL_BUTTON_Y = 61; + private static final int SURVIVAL_PREVIEW_MIN_X = 20; + private static final int SURVIVAL_PREVIEW_MAX_X = 82; + private static final int SURVIVAL_PREVIEW_MIN_Y = 4; + private static final int SURVIVAL_PREVIEW_MAX_Y = 82; + private static final int CREATIVE_CYCLE_X = 72; + private static final int CREATIVE_REPAIR_X = 90; + private static final int CREATIVE_BUTTON_Y = 34; + private static final int CREATIVE_PREVIEW_MIN_X = 73; + private static final int CREATIVE_PREVIEW_MAX_X = 105; + private static final int CREATIVE_PREVIEW_MIN_Y = 6; + private static final int CREATIVE_PREVIEW_MAX_Y = 49; + private static boolean registered; + + private NonInventoryScreenHooks() { + } + + public static void register() { + if (!registered) { + registered = true; + ScreenEvents.AFTER_INIT.register((AfterInit)(client, screen, scaledWidth, scaledHeight) -> { + if (screen instanceof HandledScreen handledScreen && isPlayerInventoryScreen(screen)) { + NonInventoryScreenHooks.InventoryControls controls = new NonInventoryScreenHooks.InventoryControls(screen, handledScreen); + Screens.getButtons(screen).add(controls.repairButton); + Screens.getButtons(screen).add(controls.cycleButton); + controls.update(); + ScreenEvents.beforeRender(screen).register((BeforeRender)(current, context, mouseX, mouseY, delta) -> controls.update()); + ScreenMouseEvents.allowMouseClick(screen).register((AllowMouseClick)(current, click) -> click.button() != 0 || !controls.tryManualDamage(click)); + } + }); + } + } + + private static boolean isPlayerInventoryScreen(Screen screen) { + return screen instanceof InventoryScreen || screen instanceof CreativeInventoryScreen; + } + + private static boolean isInventoryViewVisible(Screen screen) { + return !(screen instanceof CreativeInventoryScreen creative && !creative.isInventoryTabSelected()); + } + + private static final class InventoryControls { + private final Screen screen; + private final HandledScreen handledScreen; + private final ButtonWidget repairButton; + private final NonInventoryScreenHooks.PregnancyCycleButton cycleButton; + + private InventoryControls(Screen screen, HandledScreen handledScreen) { + this.screen = screen; + this.handledScreen = handledScreen; + boolean creative = screen instanceof CreativeInventoryScreen; + int buttonSize = creative ? 16 : 18; + int iconSize = creative ? 12 : 14; + this.repairButton = new NonInventoryScreenHooks.SkinRepairButton(buttonSize, iconSize); + this.cycleButton = new NonInventoryScreenHooks.PregnancyCycleButton(buttonSize, iconSize); + } + + private void update() { + boolean inventoryVisible = NonInventoryScreenHooks.isInventoryViewVisible(this.screen); + this.positionButtons(); + ClientPlayerEntity player = MinecraftClient.getInstance().player; + int damage = player instanceof DestroyedSkinHolder holder ? holder.getDestroyedSkinDamage() : 0; + boolean repairVisible = inventoryVisible && damage > 0; + boolean nearRepairBlock = repairVisible + && player != null + && NonSkinRepairBlocks.isNearRepairBlock(player.getEntityWorld(), player.getBlockPos(), NeedsOfNature.getConfig()); + boolean allowCraftingTable = NeedsOfNature.getConfig().isCraftingTableSkinRepairAllowed(); + this.repairButton.visible = repairVisible; + this.repairButton.active = nearRepairBlock; + this.repairButton + .setTooltip( + Tooltip.of( + Text.translatable( + nearRepairBlock + ? "gui.needsofnature.repair_skin" + : (allowCraftingTable ? "gui.needsofnature.repair_skin.needs_loom_or_crafting_table" : "gui.needsofnature.repair_skin.needs_loom") + ) + ) + ); + boolean cycleVisible = inventoryVisible && NonPregnancyClientState.isCycleEnabled(); + this.cycleButton.visible = cycleVisible; + if (cycleVisible) { + PregnancyCyclePhase phase = NonPregnancyClientState.getCyclePhase(); + + Text phaseText = Text.translatable(switch (phase) { + case SAFE -> "config.needsofnature.pregnancy_cycle.safe"; + case NORMAL -> "config.needsofnature.pregnancy_cycle.normal"; + case RISKY -> "config.needsofnature.pregnancy_cycle.risky"; + case OVULATION -> "config.needsofnature.pregnancy_cycle.ovulation"; + }); + this.cycleButton.setPhase(phase); + this.cycleButton + .setTooltip( + Tooltip.of(Text.translatable("gui.needsofnature.pregnancy_cycle.tooltip", new Object[]{NonPregnancyClientState.getCycleDay(), 30, phaseText})) + ); + } + } + + private void positionButtons() { + HandledScreenAccessor accessor = (HandledScreenAccessor)this.handledScreen; + int left = accessor.non$getLeft(); + int top = accessor.non$getTop(); + if (this.screen instanceof CreativeInventoryScreen) { + this.cycleButton.setPosition(left + 72, top + 34); + this.repairButton.setPosition(left + 90, top + 34); + } else { + this.cycleButton.setPosition(left + 27, top + 61); + this.repairButton.setPosition(left + 58, top + 61); + } + } + + private boolean tryManualDamage(Click click) { + if (NonInventoryScreenHooks.isInventoryViewVisible(this.screen) && this.isInsidePlayerPreview(click.x(), click.y())) { + ClientPlayerEntity player = MinecraftClient.getInstance().player; + if (player == null || !this.hasShearsForManualDamage(player)) { + return false; + } + + if (!ClientPlayNetworking.canSend(ManualDamageDestroyedSkinC2SPayload.ID)) { + return false; + } + + ClientPlayNetworking.send(new ManualDamageDestroyedSkinC2SPayload()); + return true; + } else { + return false; + } + } + + private boolean hasShearsForManualDamage(ClientPlayerEntity player) { + ItemStack cursorStack = this.handledScreen.getScreenHandler().getCursorStack(); + return cursorStack.isOf(Items.SHEARS) || player.getMainHandStack().isOf(Items.SHEARS) || player.getOffHandStack().isOf(Items.SHEARS); + } + + private boolean isInsidePlayerPreview(double mouseX, double mouseY) { + HandledScreenAccessor accessor = (HandledScreenAccessor)this.handledScreen; + int left = accessor.non$getLeft(); + int top = accessor.non$getTop(); + return this.screen instanceof CreativeInventoryScreen + ? mouseX >= left + 73 && mouseX <= left + 105 && mouseY >= top + 6 && mouseY <= top + 49 + : mouseX >= left + 20 && mouseX <= left + 82 && mouseY >= top + 4 && mouseY <= top + 82; + } + } + + private static final class PregnancyCycleButton extends ButtonWidget { + private final int iconSize; + private PregnancyCyclePhase phase = PregnancyCyclePhase.NORMAL; + + private PregnancyCycleButton(int buttonSize, int iconSize) { + super(0, 0, buttonSize, buttonSize, net.minecraft.text.Text.translatable(""), button -> {}, DEFAULT_NARRATION_SUPPLIER); + this.iconSize = iconSize; + } + + private void setPhase(PregnancyCyclePhase phase) { + this.phase = phase == null ? PregnancyCyclePhase.NORMAL : phase; + } + + protected void drawIcon(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + Identifier texture = switch (this.phase) { + case SAFE -> NonIconButtonTextures.PREGNANCY_CYCLE_SAFE; + case NORMAL -> NonIconButtonTextures.PREGNANCY_CYCLE_NORMAL; + case RISKY -> NonIconButtonTextures.PREGNANCY_CYCLE_RISKY; + case OVULATION -> NonIconButtonTextures.PREGNANCY_CYCLE_OVULATION; + }; + GpuTextureView textureView = MinecraftClient.getInstance().getTextureManager().getTexture(texture).getGlTextureView(); + int textureWidth = Math.max(1, textureView.getWidth(0)); + int textureHeight = Math.max(1, textureView.getHeight(0)); + float displayScale = this.iconSize / 14.0F; + int drawWidth = Math.max(1, Math.round(textureWidth * displayScale)); + int drawHeight = Math.max(1, Math.round(textureHeight * displayScale)); + int anchorX = this.getX() + (this.getWidth() - this.iconSize) / 2 + -2; + int anchorBottom = this.getY() + (this.getHeight() - this.iconSize) / 2 + this.iconSize + -1; + context.drawTexture( + RenderPipelines.GUI_TEXTURED, + texture, + anchorX, + anchorBottom - drawHeight, + 0.0F, + 0.0F, + drawWidth, + drawHeight, + textureWidth, + textureHeight, + textureWidth, + textureHeight + ); + } + + public void playDownSound(SoundManager soundManager) { + } + } + + private static final class SkinRepairButton extends ButtonWidget { + private final int iconSize; + + private SkinRepairButton(int buttonSize, int iconSize) { + super(0, 0, buttonSize, buttonSize, net.minecraft.text.Text.translatable(""), button -> { + if (ClientPlayNetworking.canSend(RepairDestroyedSkinC2SPayload.ID)) { + ClientPlayNetworking.send(new RepairDestroyedSkinC2SPayload()); + } + }, DEFAULT_NARRATION_SUPPLIER); + this.iconSize = iconSize; + } + + protected void drawIcon(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + int iconX = this.getX() + (this.getWidth() - this.iconSize) / 2; + int iconY = this.getY() + (this.getHeight() - this.iconSize) / 2; + int color = this.active ? -1 : -2130706433; + context.drawTexture( + RenderPipelines.GUI_TEXTURED, + NonIconButtonTextures.SKIN_REPAIR, + iconX, + iconY, + 0.0F, + 0.0F, + this.iconSize, + this.iconSize, + this.iconSize, + this.iconSize, + color + ); + } + + public void playDownSound(SoundManager soundManager) { + } + } +} diff --git a/src/main/java/com/nonid/client/NonLiquidColors.java b/src/main/java/com/nonid/client/NonLiquidColors.java new file mode 100644 index 0000000..57526bf --- /dev/null +++ b/src/main/java/com/nonid/client/NonLiquidColors.java @@ -0,0 +1,82 @@ +package com.nonid.client; + +import com.nonid.NonConfig; +import java.util.Locale; +import java.util.Map; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonLiquidColors { + public static final int MIXED_RGB = 15920063; + public static final int ENTITY_RGB = 16776427; + + private NonLiquidColors() { + } + + public static int toOpaqueArgb(int rgb) { + return 0xFF000000 | rgb & 16777215; + } + + public static int resolveEntityRgb(@Nullable Identifier entityTypeId, @Nullable NonConfig config) { + if (entityTypeId != null && config != null) { + Integer override = config.getLiquidColorOverrideRgb(entityTypeId.toString()); + return override != null ? override : 16776427; + } else { + return 16776427; + } + } + + @Nullable + public static String normalizeHexColor(@Nullable String raw) { + if (raw == null) { + return null; + } + + String text = raw.trim(); + if (text.isEmpty()) { + return null; + } + + if (text.startsWith("#")) { + text = text.substring(1); + } + + if (text.length() != 6) { + return null; + } + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + boolean isHex = c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'; + if (!isHex) { + return null; + } + } + + return text.toUpperCase(Locale.ROOT); + } + + @Nullable + public static Integer parseHexColor(@Nullable String raw) { + String normalized = normalizeHexColor(raw); + if (normalized == null) { + return null; + } + + try { + return Integer.parseInt(normalized, 16) & 16777215; + } catch (NumberFormatException e) { + return null; + } + } + + @Nullable + public static Integer resolveEntityOverrideFromMap(@Nullable Map map, @Nullable String entityId) { + if (map != null && entityId != null) { + String hex = map.get(entityId); + return hex == null ? null : parseHexColor(hex); + } else { + return null; + } + } +} diff --git a/src/main/java/com/nonid/client/NonPackStartupWarnings.java b/src/main/java/com/nonid/client/NonPackStartupWarnings.java new file mode 100644 index 0000000..4525032 --- /dev/null +++ b/src/main/java/com/nonid/client/NonPackStartupWarnings.java @@ -0,0 +1,229 @@ +package com.nonid.client; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.pack.NonExternalPackProvider; +import com.nonid.pack.NonPackRootResolver; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.TitleScreen; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.jetbrains.annotations.Nullable; + +public final class NonPackStartupWarnings { + private static final String WARNING_PATH = "assets/needsofnature/non_warnings.json"; + private static boolean checkedThisSession = false; + + private NonPackStartupWarnings() { + } + + public static void clientTick(MinecraftClient client) { + if (client != null && !checkedThisSession) { + if (client.getOverlay() == null) { + if (client.currentScreen instanceof TitleScreen) { + checkedThisSession = true; + NonPackStartupWarnings.Warning warning = findHighestPriorityWarning(); + if (warning != null && !isAcknowledged(warning.id())) { + client.setScreen(new NonPackWarningScreen(client.currentScreen, warning)); + } + } + } + } + } + + public static void acknowledge(String warningId) { + if (warningId != null && !warningId.isBlank()) { + NonConfig config = NeedsOfNature.getLocalConfig(); + if (config != null && config.acknowledgeStartupWarning(warningId)) { + config.save(); + } + } + } + + @Nullable + private static NonPackStartupWarnings.Warning findHighestPriorityWarning() { + Path packsDir = NonExternalPackProvider.resolveDefaultPacksDir(); + List discovered = NonExternalPackProvider.listExternalPackFileNames(packsDir); + if (discovered.isEmpty()) { + return null; + } + + NonConfig config = NeedsOfNature.getLocalConfig(); + List configuredOrder = config == null ? List.of() : config.getExternalNoNPackLoadOrder(); + + for (String packName : NonExternalPackProvider.normalizeConfiguredOrder(configuredOrder, discovered)) { + NonPackStartupWarnings.Warning warning = readWarning(packsDir.resolve(packName), packName); + if (warning != null) { + return warning; + } + } + + return null; + } + + @Nullable + private static NonPackStartupWarnings.Warning readWarning(Path packPath, String packName) { + if (packPath != null && packName != null) { + try { + Optional resolved = NonPackRootResolver.resolve(packPath); + if (resolved.isEmpty()) { + return null; + } + + NonPackRootResolver.ResolvedRoot root = resolved.get(); + if (!root.isZip()) { + Path warningPath = root.rootPath() + .resolve("assets/needsofnature/non_warnings.json".replace("/", root.rootPath().getFileSystem().getSeparator())); + if (!Files.isRegularFile(warningPath)) { + return null; + } + + try (Reader reader = Files.newBufferedReader(warningPath, StandardCharsets.UTF_8)) { + return parseWarning(reader, packName); + } + } else { + try (ZipFile zip = new ZipFile(packPath.toFile())) { + ZipEntry entry = zip.getEntry(root.prefixPath("assets/needsofnature/non_warnings.json")); + if (entry == null || entry.isDirectory()) { + return null; + } + + try (Reader reader = new InputStreamReader(zip.getInputStream(entry), StandardCharsets.UTF_8)) { + return parseWarning(reader, packName); + } + } + } + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to load startup warning from pack {}.", packName, e); + return null; + } + } else { + return null; + } + } + + @Nullable + private static NonPackStartupWarnings.Warning parseWarning(Reader reader, String packName) { + JsonElement root = JsonParser.parseReader(reader); + if (!root.isJsonObject()) { + warnInvalid(packName, "root must be an object"); + return null; + } + + JsonObject obj = root.getAsJsonObject(); + String id = stringOrNull(obj, "id"); + if (id != null && !id.isBlank()) { + Text title = readText(obj, "title", "title_key"); + Text body = readText(obj, "text", "text_key"); + if (title != null && body != null) { + return new NonPackStartupWarnings.Warning(packName, id.trim(), title, body); + } + + warnInvalid(packName, "missing title/text or title_key/text_key"); + return null; + } else { + warnInvalid(packName, "missing id"); + return null; + } + } + + @Nullable + private static Text readText(JsonObject obj, String literalKey, String translationKey) { + String key = stringOrNull(obj, translationKey); + if (key != null && !key.isBlank()) { + return Text.translatable(key.trim()); + } + + String literal = stringOrNull(obj, literalKey); + return literal != null && !literal.isBlank() ? parseFormattedLiteral(literal) : null; + } + + private static MutableText parseFormattedLiteral(String raw) { + MutableText out = Text.literal(""); + List active = new ArrayList<>(); + StringBuilder segment = new StringBuilder(); + + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '&' && i + 1 < raw.length()) { + Formatting formatting = Formatting.byCode(raw.charAt(i + 1)); + if (formatting == null) { + segment.append(c); + } else { + appendSegment(out, segment, active); + i++; + if (formatting == Formatting.RESET) { + active.clear(); + } else if (formatting.isColor()) { + active.removeIf(Formatting::isColor); + active.add(formatting); + } else if (!active.contains(formatting)) { + active.add(formatting); + } + } + } else { + segment.append(c); + } + } + + appendSegment(out, segment, active); + return out; + } + + private static void appendSegment(MutableText out, StringBuilder segment, List active) { + if (!segment.isEmpty()) { + MutableText text = Text.literal(segment.toString()); + if (!active.isEmpty()) { + text.formatted(active.toArray(Formatting[]::new)); + } + + out.append(text); + segment.setLength(0); + } + } + + @Nullable + private static String stringOrNull(JsonObject obj, String key) { + if (obj != null && key != null && obj.has(key)) { + JsonElement element = obj.get(key); + if (element != null && element.isJsonPrimitive()) { + try { + return element.getAsString(); + } catch (RuntimeException e) { + return null; + } + } else { + return null; + } + } else { + return null; + } + } + + private static boolean isAcknowledged(String warningId) { + NonConfig config = NeedsOfNature.getLocalConfig(); + return config != null && config.isStartupWarningAcknowledged(warningId); + } + + private static void warnInvalid(String packName, String reason) { + NeedsOfNature.LOGGER.warn("[NoN] Ignoring startup warning in pack {}: {}.", packName, reason); + } + + public record Warning(String packName, String id, Text title, Text body) { + } +} diff --git a/src/main/java/com/nonid/client/NonPackWarningScreen.java b/src/main/java/com/nonid/client/NonPackWarningScreen.java new file mode 100644 index 0000000..373c43c --- /dev/null +++ b/src/main/java/com/nonid/client/NonPackWarningScreen.java @@ -0,0 +1,126 @@ +package com.nonid.client; + +import java.util.List; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.CheckboxWidget; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; + +public final class NonPackWarningScreen extends Screen { + private static final long PROCEED_DELAY_MS = 10000L; + private static final int CONTENT_WIDTH = 340; + private final Screen parent; + private final NonPackStartupWarnings.Warning warning; + private final long openedAtMs; + private ButtonWidget proceedButton; + private CheckboxWidget dontShowAgainCheckbox; + + public NonPackWarningScreen(Screen parent, NonPackStartupWarnings.Warning warning) { + super(warning.title()); + this.parent = parent; + this.warning = warning; + this.openedAtMs = System.currentTimeMillis(); + } + + protected void init() { + int centerX = this.width / 2; + int buttonY = Math.min(this.height - 38, 246); + int checkboxY = buttonY - 28; + this.dontShowAgainCheckbox = CheckboxWidget.builder(Text.translatable("screen.needsofnature.warning.dont_show_again"), this.textRenderer) + .pos(centerX - 170, checkboxY) + .maxWidth(340) + .build(); + this.addDrawableChild(this.dontShowAgainCheckbox); + this.proceedButton = ButtonWidget.builder(this.proceedText(), button -> this.proceed()).dimensions(centerX - 165, buttonY, 160, 20).build(); + this.addDrawableChild(this.proceedButton); + this.addDrawableChild(ButtonWidget.builder(Text.translatable("screen.needsofnature.warning.close_game"), button -> { + MinecraftClient client = this.client; + if (client != null) { + client.scheduleStop(); + } + }).dimensions(centerX + 5, buttonY, 160, 20).build()); + this.updateProceedButton(); + } + + public void tick() { + this.updateProceedButton(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + context.fill(0, 0, this.width, this.height, -267382768); + int centerX = this.width / 2; + int top = 34; + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, top, -43691); + int boxX = centerX - 170 - 12; + int boxY = top + 22; + int boxWidth = 364; + int boxHeight = 118; + context.fill(boxX, boxY, boxX + boxWidth, boxY + boxHeight, -1442840576); + context.fill(boxX, boxY, boxX + boxWidth, boxY + 1, 1728053247); + context.fill(boxX, boxY + boxHeight - 1, boxX + boxWidth, boxY + boxHeight, 1711276032); + List lines = this.textRenderer.wrapLines(this.warning.body(), 340); + int y = boxY + 14; + + for (OrderedText line : lines) { + if (y > boxY + boxHeight - 14) { + break; + } + + context.drawTextWithShadow(this.textRenderer, line, centerX - 170, y, -1644826); + y += 11; + } + + Text packText = Text.translatable("screen.needsofnature.warning.pack", new Object[]{this.warning.packName()}); + int packTextWidth = this.textRenderer.getWidth(packText); + context.drawTextWithShadow(this.textRenderer, packText, Math.max(4, this.width - packTextWidth - 6), this.height - 12, -5592406); + super.render(context, mouseX, mouseY, deltaTicks); + } + + public boolean shouldCloseOnEsc() { + return false; + } + + public void close() { + } + + private void proceed() { + if (this.canProceed()) { + if (this.dontShowAgainCheckbox != null && this.dontShowAgainCheckbox.isChecked()) { + NonPackStartupWarnings.acknowledge(this.warning.id()); + } + + MinecraftClient client = this.client; + if (client != null) { + client.setScreen(this.parent); + } + } + } + + private void updateProceedButton() { + if (this.proceedButton != null) { + this.proceedButton.active = this.canProceed(); + this.proceedButton.setMessage(this.proceedText()); + } + } + + private Text proceedText() { + long remainingMs = Math.max(0L, 10000L - this.elapsedMs()); + if (remainingMs <= 0L) { + return Text.translatable("screen.needsofnature.warning.proceed"); + } + + long remainingSeconds = Math.max(1L, (remainingMs + 999L) / 1000L); + return Text.translatable("screen.needsofnature.warning.proceed_countdown", new Object[]{remainingSeconds}); + } + + private boolean canProceed() { + return this.elapsedMs() >= 10000L; + } + + private long elapsedMs() { + return System.currentTimeMillis() - this.openedAtMs; + } +} diff --git a/src/main/java/com/nonid/client/NonPlayerInteractionClient.java b/src/main/java/com/nonid/client/NonPlayerInteractionClient.java new file mode 100644 index 0000000..3bb1d62 --- /dev/null +++ b/src/main/java/com/nonid/client/NonPlayerInteractionClient.java @@ -0,0 +1,848 @@ +package com.nonid.client; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.nonid.NeedsOfNature; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import com.nonid.network.PlayerAnimationCancelC2SPayload; +import com.nonid.network.PlayerAnimationChoiceC2SPayload; +import com.nonid.network.PlayerAnimationConsentC2SPayload; +import com.nonid.network.PlayerAnimationConsentS2CPayload; +import com.nonid.network.PlayerAnimationRequestStatusS2CPayload; +import com.nonid.network.PlayerAnimationSelectionS2CPayload; +import com.nonid.network.PlayerProfileActionC2SPayload; +import com.nonid.network.PlayerProfileSnapshotS2CPayload; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.PlayerSkinDrawer; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.ElementListWidget.Entry; +import net.minecraft.client.network.PlayerListEntry; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; + +public final class NonPlayerInteractionClient { + private static final Gson GSON = new Gson(); + private static final Identifier DEFAULT_PLAYER_SKIN = Identifier.ofVanilla("textures/entity/player/wide/steve.png"); + private static int profileRevision; + private static NonPlayerInteractionClient.ProfileSnapshot profileSnapshot = new NonPlayerInteractionClient.ProfileSnapshot( + "requests_and_attacks", 15, List.of(), List.of() + ); + + private NonPlayerInteractionClient() { + } + + public static void register() { + UseEntityCallback.EVENT + .register( + (UseEntityCallback)(player, world, hand, entity, hitResult) -> { + if (world.isClient() + && hand == Hand.MAIN_HAND + && player.getMainHandStack().isEmpty() + && !player.isSpectator() + && entity != null + && !entity.isRemoved() + && !entity.getUuid().equals(player.getUuid())) { + boolean directRequest = player.isSneaking() && entity instanceof PlayerEntity; + boolean joinRequest = ClientAnimationRuntime.findLatestActiveInstanceContaining(entity.getUuid()) != null; + return (ActionResult)(!directRequest && !joinRequest ? ActionResult.PASS : ActionResult.SUCCESS); + } else { + return ActionResult.PASS; + } + } + ); + ClientPlayNetworking.registerGlobalReceiver( + PlayerProfileSnapshotS2CPayload.ID, (payload, context) -> context.client().execute(() -> applyProfileSnapshot(payload)) + ); + ClientPlayNetworking.registerGlobalReceiver( + PlayerAnimationSelectionS2CPayload.ID, (payload, context) -> context.client().execute(() -> openSelection(payload)) + ); + ClientPlayNetworking.registerGlobalReceiver( + PlayerAnimationConsentS2CPayload.ID, (payload, context) -> context.client().execute(() -> openConsent(payload)) + ); + ClientPlayNetworking.registerGlobalReceiver( + PlayerAnimationRequestStatusS2CPayload.ID, (payload, context) -> context.client().execute(() -> applyRequestStatus(payload)) + ); + } + + public static void clear() { + profileRevision++; + profileSnapshot = new NonPlayerInteractionClient.ProfileSnapshot("requests_and_attacks", 15, List.of(), List.of()); + } + + public static int profileRevision() { + return profileRevision; + } + + public static NonPlayerInteractionClient.ProfileSnapshot profileSnapshot() { + return profileSnapshot; + } + + public static void requestProfiles() { + sendProfileAction("request", "", ""); + } + + public static void setDefaultProfile(String mode) { + sendProfileAction("default", "", mode); + } + + public static void setPlayerProfile(String playerNameOrUuid, String mode) { + sendProfileAction("set", playerNameOrUuid, mode); + } + + public static void removePlayerProfile(String playerUuid) { + sendProfileAction("remove", playerUuid, ""); + } + + private static void sendProfileAction(String action, String target, String mode) { + if (ClientPlayNetworking.canSend(PlayerProfileActionC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerProfileActionC2SPayload(action, target, mode)); + } + } + + private static void applyProfileSnapshot(PlayerProfileSnapshotS2CPayload payload) { + try { + NonPlayerInteractionClient.ProfileSnapshot parsed = (NonPlayerInteractionClient.ProfileSnapshot)GSON.fromJson( + payload.json(), NonPlayerInteractionClient.ProfileSnapshot.class + ); + if (parsed != null) { + profileSnapshot = parsed; + profileRevision++; + } + } catch (JsonParseException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to parse player interaction profile snapshot.", e); + } + + if (payload.errorKey() != null && !payload.errorKey().isBlank()) { + sendLocalMessage(payload.errorKey()); + } + } + + private static void openSelection(PlayerAnimationSelectionS2CPayload payload) { + NonPlayerInteractionClient.Candidate[] parsed; + try { + parsed = (NonPlayerInteractionClient.Candidate[])GSON.fromJson(payload.candidatesJson(), NonPlayerInteractionClient.Candidate[].class); + } catch (JsonParseException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to parse player animation candidates.", e); + sendLocalMessage("gui.needsofnature.player_request.invalid"); + return; + } + + List candidates = parsed == null ? List.of() : List.of(parsed); + if (candidates.isEmpty()) { + sendLocalMessage("gui.needsofnature.player_request.no_match"); + } else { + MinecraftClient client = MinecraftClient.getInstance(); + client.setScreen(new NonPlayerInteractionClient.AnimationSelectionScreen(payload.requestId(), payload.targetName(), candidates, payload.joinRequest())); + } + } + + private static void openConsent(PlayerAnimationConsentS2CPayload payload) { + List tags; + try { + String[] parsed = (String[])GSON.fromJson(payload.contentTagsJson(), String[].class); + tags = parsed == null ? List.of() : List.of(parsed); + } catch (JsonParseException e) { + tags = List.of(); + } + + MinecraftClient.getInstance() + .setScreen( + new NonPlayerInteractionClient.ConsentScreen( + payload.requestId(), payload.requesterUuid(), payload.requesterName(), payload.animationId(), payload.displayName(), tags, payload.joinRequest() + ) + ); + } + + private static void applyRequestStatus(PlayerAnimationRequestStatusS2CPayload payload) { + MinecraftClient client = MinecraftClient.getInstance(); + Screen current = client.currentScreen; + if ("waiting".equals(payload.state())) { + client.setScreen(new NonPlayerInteractionClient.WaitingScreen(payload.requestId())); + } else { + if (current instanceof NonPlayerInteractionClient.RequestBoundScreen requestScreen && requestScreen.requestId().equals(payload.requestId())) { + requestScreen.markResolved(); + client.setScreen(null); + } + + if (payload.messageKey() != null && !payload.messageKey().isBlank() && !"started".equals(payload.state())) { + if (usesRequestActionBar(payload.messageKey())) { + showRequestActionBar(payload.messageKey()); + } else { + sendLocalMessage(payload.messageKey()); + } + } + } + } + + private static boolean usesRequestActionBar(String translationKey) { + return "gui.needsofnature.player_request.rejected".equals(translationKey) || "gui.needsofnature.player_request.energy_low".equals(translationKey); + } + + private static void showRequestActionBar(String translationKey) { + MinecraftClient client = MinecraftClient.getInstance(); + if (translationKey != null && !translationKey.isBlank()) { + Text message = "gui.needsofnature.player_request.energy_low".equals(translationKey) + ? Text.translatable(translationKey, new Object[]{NeedsOfNature.getConfig().getPlayerRequestMinEnergy()}) + : Text.translatable(translationKey); + client.inGameHud.setOverlayMessage(message, false); + } + } + + private static void sendLocalMessage(String translationKey) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && translationKey != null && !translationKey.isBlank()) { + client.player.sendMessage(Text.translatable(translationKey), false); + } + } + + public static void drawPlayerFace(DrawContext context, UUID playerUuid, int x, int y, int size) { + MinecraftClient client = MinecraftClient.getInstance(); + Identifier texture = DEFAULT_PLAYER_SKIN; + if (client.getNetworkHandler() != null && playerUuid != null) { + PlayerListEntry entry = client.getNetworkHandler().getPlayerListEntry(playerUuid); + if (entry != null && entry.getSkinTextures() != null && entry.getSkinTextures().body() != null) { + PlayerSkinDrawer.draw(context, entry.getSkinTextures(), x, y, size); + return; + } + } + + drawTextureRegion(context, texture, x, y, 8, 8, size, size, 8, 8); + drawTextureRegion(context, texture, x, y, 40, 8, size, size, 8, 8); + } + + private static void drawTextureRegion( + DrawContext context, Identifier texture, int x, int y, int u, int v, int width, int height, int regionWidth, int regionHeight + ) { + context.drawTexture(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 64, 64, regionWidth, regionHeight); + } + + private static Tooltip blockRequirementTooltip(String requirement) { + String summary = requirement != null && !requirement.isBlank() ? requirement : "block requirement"; + return Tooltip.of(Text.translatable("gui.needsofnature.player_request.block_requirement_tooltip", new Object[]{summary})); + } + + private static Tooltip animationCardTooltip(NonPlayerInteractionClient.CandidateGroup group) { + MutableText text = Text.literal(shortAnimationId(group.animationId())); + if (!group.selectable()) { + String summary = group.blockRequirement() != null && !group.blockRequirement().isBlank() ? group.blockRequirement() : "block requirement"; + text.append(Text.literal("\n")).append(Text.translatable("gui.needsofnature.player_request.block_requirement_tooltip", new Object[]{summary})); + } + + return Tooltip.of(text); + } + + private static String shortAnimationId(String raw) { + Identifier id = Identifier.tryParse(raw); + return id == null ? raw : id.getPath(); + } + + private static final class AnimationSelectionScreen extends Screen implements NonPlayerInteractionClient.RequestBoundScreen { + private final UUID requestId; + private final String targetName; + private final List groups; + private final boolean joinRequest; + private boolean resolved; + + private AnimationSelectionScreen(UUID requestId, String targetName, List candidates, boolean joinRequest) { + super(Text.translatable(joinRequest ? "gui.needsofnature.player_request.select_join_title" : "gui.needsofnature.player_request.select_title")); + this.requestId = requestId; + this.targetName = targetName == null ? "Player" : targetName; + this.joinRequest = joinRequest; + Map> grouped = new LinkedHashMap<>(); + + for (NonPlayerInteractionClient.Candidate candidate : candidates) { + grouped.computeIfAbsent(candidate.animation_id(), ignored -> new ArrayList<>()).add(candidate); + } + + this.groups = grouped.entrySet() + .stream() + .map(entry -> new NonPlayerInteractionClient.CandidateGroup(entry.getKey(), List.copyOf(entry.getValue()))) + .toList(); + } + + protected void init() { + int top = 48; + int bottom = 34; + NonPlayerInteractionClient.CandidateGridList list = new NonPlayerInteractionClient.CandidateGridList( + this.client, this.width, Math.max(40, this.height - top - bottom), top, this.textRenderer, this::chooseGroup + ); + list.setGroups(this.groups); + this.addDrawableChild(list); + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.cancel"), ignored -> this.cancel()).dimensions(this.width / 2 - 75, this.height - 28, 150, 20).build() + ); + } + + private void chooseGroup(NonPlayerInteractionClient.CandidateGroup group) { + if (group.candidates().size() == 1) { + if (group.first().selectable()) { + this.choose(group.first()); + } + } else { + MinecraftClient.getInstance().setScreen(new NonPlayerInteractionClient.RoleSelectionScreen(this, this.requestId, group)); + } + } + + private void choose(NonPlayerInteractionClient.Candidate candidate) { + if (ClientPlayNetworking.canSend(PlayerAnimationChoiceC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerAnimationChoiceC2SPayload(this.requestId, candidate.index())); + } + } + + private void cancel() { + if (!this.resolved && ClientPlayNetworking.canSend(PlayerAnimationCancelC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerAnimationCancelC2SPayload(this.requestId)); + } + + this.resolved = true; + MinecraftClient.getInstance().setScreen(null); + } + + public void close() { + this.cancel(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.translatable( + this.joinRequest ? "gui.needsofnature.player_request.select_join_for" : "gui.needsofnature.player_request.select_for", + new Object[]{this.targetName} + ), + this.width / 2, + 30, + -5592406 + ); + } + + @Override + public UUID requestId() { + return this.requestId; + } + + @Override + public void markResolved() { + this.resolved = true; + } + } + + private record Candidate( + int index, + String animation_id, + String display_name, + String requester_role, + String requester_activity, + boolean selectable, + String block_requirement, + List actor_keys, + List content_tags, + String pack_name, + String pack_author + ) { + private Candidate { + animation_id = animation_id == null ? "unknown" : animation_id; + display_name = display_name == null ? "" : display_name.trim(); + requester_role = requester_role == null ? "player" : requester_role; + requester_activity = "passive".equalsIgnoreCase(requester_activity) ? "passive" : "active"; + block_requirement = block_requirement == null ? "" : block_requirement; + actor_keys = actor_keys == null ? List.of() : List.copyOf(actor_keys); + content_tags = content_tags == null ? List.of() : List.copyOf(content_tags); + pack_name = pack_name == null ? "" : pack_name; + pack_author = pack_author == null ? "" : pack_author; + } + } + + private static final class CandidateGridList extends ElementListWidget { + private final TextRenderer textRenderer; + private final Consumer onChoose; + + private CandidateGridList( + MinecraftClient client, int width, int height, int top, TextRenderer textRenderer, Consumer onChoose + ) { + super(client, width, height, top, 124); + this.textRenderer = textRenderer; + this.onChoose = onChoose; + this.centerListVertically = false; + } + + private void setGroups(List groups) { + this.clearEntries(); + List available = groups.stream().filter(NonPlayerInteractionClient.CandidateGroup::selectable).toList(); + List unavailable = groups.stream().filter(group -> !group.selectable()).toList(); + this.addSection(Text.translatable("gui.needsofnature.player_request.section.available"), available); + this.addSection(Text.translatable("gui.needsofnature.player_request.section.unavailable"), unavailable); + } + + private void addSection(Text title, List groups) { + if (!groups.isEmpty()) { + this.addEntry(new NonPlayerInteractionClient.CandidateGridList.RowEntry(title, this.textRenderer), 22); + + for (int index = 0; index < groups.size(); index += 3) { + this.addEntry( + new NonPlayerInteractionClient.CandidateGridList.RowEntry( + groups.subList(index, Math.min(groups.size(), index + 3)), this.textRenderer, this.onChoose + ) + ); + } + } + } + + public int getRowWidth() { + return Math.min(510, Math.max(240, this.getWidth() - 28)); + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + protected int getScrollbarX() { + return this.getWidth() - 8; + } + + private static final class RowEntry extends Entry { + private final Text sectionTitle; + private final TextRenderer textRenderer; + private final List cards; + + private RowEntry(Text sectionTitle, TextRenderer textRenderer) { + this.sectionTitle = sectionTitle; + this.textRenderer = textRenderer; + this.cards = List.of(); + } + + private RowEntry( + List groups, TextRenderer textRenderer, Consumer onChoose + ) { + this.sectionTitle = null; + this.textRenderer = textRenderer; + List widgets = new ArrayList<>(); + + for (NonPlayerInteractionClient.CandidateGroup group : groups) { + widgets.add(new NonPlayerInteractionClient.PreviewCardWidget(group, textRenderer, onChoose)); + } + + this.cards = List.copyOf(widgets); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + if (this.sectionTitle != null) { + int centerX = this.getContentMiddleX(); + int textWidth = this.textRenderer.getWidth(this.sectionTitle); + int lineY = this.getContentMiddleY(); + context.fill(this.getContentX(), lineY, centerX - textWidth / 2 - 8, lineY + 1, -11908534); + context.fill(centerX + textWidth / 2 + 8, lineY, this.getContentRightEnd(), lineY + 1, -11908534); + context.drawCenteredTextWithShadow(this.textRenderer, this.sectionTitle, centerX, lineY - 9 / 2, -5197648); + } else { + int gap = 6; + int cardWidth = Math.max(70, (this.getContentWidth() - gap * 2) / 3); + int x = this.getContentX(); + int y = this.getContentY() + 2; + + for (NonPlayerInteractionClient.PreviewCardWidget card : this.cards) { + card.setX(x); + card.setY(y); + card.setWidth(cardWidth); + card.render(context, mouseX, mouseY, deltaTicks); + x += cardWidth + gap; + } + } + } + + public List selectableChildren() { + return this.cards; + } + + public List children() { + return this.cards; + } + + public void forEachChild(Consumer consumer) { + this.cards.forEach(consumer); + } + } + } + + private record CandidateGroup(String animationId, List candidates) { + private NonPlayerInteractionClient.Candidate first() { + return this.candidates.getFirst(); + } + + private boolean selectable() { + return this.candidates.stream().anyMatch(NonPlayerInteractionClient.Candidate::selectable); + } + + private boolean hasDisplayName() { + return !this.first().display_name().isBlank(); + } + + private String displayName() { + return this.hasDisplayName() ? this.first().display_name() : NonPlayerInteractionClient.shortAnimationId(this.animationId); + } + + private String blockRequirement() { + return this.candidates + .stream() + .map(NonPlayerInteractionClient.Candidate::block_requirement) + .filter(value -> value != null && !value.isBlank()) + .findFirst() + .orElse("block requirement"); + } + } + + private static final class ConsentScreen extends Screen implements NonPlayerInteractionClient.RequestBoundScreen { + private final UUID requestId; + private final UUID requesterUuid; + private final String requesterName; + private final String animationId; + private final String displayName; + private final List contentTags; + private final boolean joinRequest; + private boolean resolved; + + private ConsentScreen( + UUID requestId, UUID requesterUuid, String requesterName, String animationId, String displayName, List contentTags, boolean joinRequest + ) { + super(Text.translatable("gui.needsofnature.player_request.consent_title")); + this.requestId = requestId; + this.requesterUuid = requesterUuid; + this.requesterName = requesterName; + this.animationId = animationId; + this.displayName = displayName != null && !displayName.isBlank() ? displayName.trim() : NonPlayerInteractionClient.shortAnimationId(animationId); + this.contentTags = contentTags; + this.joinRequest = joinRequest; + } + + protected void init() { + int top = Math.max(24, this.height / 2 - 88); + int y = Math.min(this.height - 28, top + 112); + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.needsofnature.player_request.accept"), ignored -> this.respond(true)) + .dimensions(this.width / 2 - 104, y, 100, 20) + .build() + ); + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.needsofnature.player_request.reject"), ignored -> this.respond(false)) + .dimensions(this.width / 2 + 4, y, 100, 20) + .build() + ); + } + + private void respond(boolean accepted) { + if (!this.resolved) { + this.resolved = true; + if (ClientPlayNetworking.canSend(PlayerAnimationConsentC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerAnimationConsentC2SPayload(this.requestId, accepted)); + } + + MinecraftClient.getInstance().setScreen(null); + } + } + + public void close() { + this.respond(false); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + int centerX = this.width / 2; + int top = Math.max(24, this.height / 2 - 88); + context.fill(centerX - 180, top - 10, centerX + 180, top + 138, -585623528); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, top, -1); + NonPlayerInteractionClient.drawPlayerFace(context, this.requesterUuid, centerX - 96, top + 22, 32); + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(this.requesterName), centerX - 80, top + 58, -1); + NonAnimationPreviewRenderer.draw(context, this.textRenderer, this.animationId, centerX + 24, top + 17, 64); + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.translatable( + this.joinRequest ? "gui.needsofnature.player_request.join_prompt" : "gui.needsofnature.player_request.start_prompt", + new Object[]{this.displayName} + ), + centerX, + top + 84, + -3092272 + ); + if (!this.contentTags.isEmpty()) { + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.literal(String.join(" | ", this.contentTags.stream().map(NonTagLocalizer::localize).toList())), + centerX, + top + 97, + -6645094 + ); + } + + super.render(context, mouseX, mouseY, deltaTicks); + } + + @Override + public UUID requestId() { + return this.requestId; + } + + @Override + public void markResolved() { + this.resolved = true; + } + } + + public record KnownPlayerEntry(String uuid, String name) { + } + + private static final class PreviewCardWidget extends ClickableWidget { + private final NonPlayerInteractionClient.CandidateGroup group; + private final TextRenderer textRenderer; + private final Consumer onChoose; + + private PreviewCardWidget( + NonPlayerInteractionClient.CandidateGroup group, TextRenderer textRenderer, Consumer onChoose + ) { + super(0, 0, 100, 116, Text.literal(group.displayName())); + this.group = group; + this.textRenderer = textRenderer; + this.onChoose = onChoose; + this.active = group.selectable(); + if (!this.active || group.hasDisplayName()) { + this.setTooltip(NonPlayerInteractionClient.animationCardTooltip(group)); + } + } + + protected void renderWidget(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + int border = !this.active ? -12632257 : (this.isSelected() ? -1002406 : -11184811); + int background = !this.active ? -300542442 : (this.isSelected() ? -298636510 : -585426149); + context.fill(this.getX(), this.getY(), this.getX() + this.getWidth(), this.getY() + this.getHeight(), border); + context.fill(this.getX() + 1, this.getY() + 1, this.getX() + this.getWidth() - 1, this.getY() + this.getHeight() - 1, background); + int previewSize = Math.min(72, Math.max(42, this.getWidth() - 18)); + int previewX = this.getX() + (this.getWidth() - previewSize) / 2; + int previewY = this.getY() + 6; + NonAnimationPreviewRenderer.draw(context, this.textRenderer, this.group.animationId(), previewX, previewY, previewSize); + if (!this.active) { + context.fill(previewX, previewY, previewX + previewSize, previewY + previewSize, -1728053248); + } + + NonPlayerInteractionClient.Candidate first = this.group.first(); + String id = this.group.displayName(); + context.drawCenteredTextWithShadow( + this.textRenderer, + this.textRenderer.trimToWidth(id, Math.max(20, this.getWidth() - 8)), + this.getX() + this.getWidth() / 2, + previewY + previewSize + 5, + this.active ? -1 : -8947849 + ); + if (!this.active) { + context.drawCenteredTextWithShadow( + this.textRenderer, + this.textRenderer + .trimToWidth( + Text.translatable("gui.needsofnature.player_request.block_requirement_unavailable").getString(), Math.max(20, this.getWidth() - 8) + ), + this.getX() + this.getWidth() / 2, + previewY + previewSize + 16, + -6710887 + ); + context.drawCenteredTextWithShadow( + this.textRenderer, + this.textRenderer + .trimToWidth( + Text.translatable("gui.needsofnature.player_request.block_requirement_summary", new Object[]{this.group.blockRequirement()}).getString(), + Math.max(20, this.getWidth() - 8) + ), + this.getX() + this.getWidth() / 2, + this.getY() + this.getHeight() - 11, + -8947849 + ); + } else { + String tags = first.content_tags().isEmpty() + ? "" + : String.join(" | ", first.content_tags().stream().map(NonTagLocalizer::localize).toList()); + if (!tags.isBlank()) { + context.drawCenteredTextWithShadow( + this.textRenderer, + this.textRenderer.trimToWidth(tags, Math.max(20, this.getWidth() - 8)), + this.getX() + this.getWidth() / 2, + previewY + previewSize + 16, + -6645094 + ); + } + + if (this.group.candidates().size() > 1) { + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.translatable("gui.needsofnature.player_request.roles", new Object[]{this.group.candidates().size()}), + this.getX() + this.getWidth() / 2, + this.getY() + this.getHeight() - 11, + -1853325 + ); + } + } + } + + public void onClick(Click click, boolean doubled) { + super.onClick(click, doubled); + if (this.active && this.onChoose != null) { + this.onChoose.accept(this.group); + } + } + + protected void appendClickableNarrations(NarrationMessageBuilder builder) { + this.appendDefaultNarrations(builder); + } + } + + public record ProfileEntry(String uuid, String name, String mode) { + } + + public record ProfileSnapshot( + String default_mode, + int allowed_mask, + List overrides, + List known_players + ) { + public ProfileSnapshot { + default_mode = default_mode == null ? "requests_and_attacks" : default_mode; + overrides = overrides == null ? List.of() : List.copyOf(overrides); + known_players = known_players == null ? List.of() : List.copyOf(known_players); + } + } + + private interface RequestBoundScreen { + UUID requestId(); + + void markResolved(); + } + + private static final class RoleSelectionScreen extends Screen implements NonPlayerInteractionClient.RequestBoundScreen { + private final NonPlayerInteractionClient.AnimationSelectionScreen parent; + private final UUID requestId; + private final NonPlayerInteractionClient.CandidateGroup group; + private boolean resolved; + + private RoleSelectionScreen(NonPlayerInteractionClient.AnimationSelectionScreen parent, UUID requestId, NonPlayerInteractionClient.CandidateGroup group) { + super(Text.translatable("gui.needsofnature.player_request.role_title")); + this.parent = parent; + this.requestId = requestId; + this.group = group; + } + + protected void init() { + int buttonWidth = Math.min(360, this.width - 40); + int startY = Math.max(42, (this.height - this.group.candidates().size() * 24) / 2); + + for (int index = 0; index < this.group.candidates().size(); index++) { + NonPlayerInteractionClient.Candidate candidate = this.group.candidates().get(index); + Text label = Text.translatable( + "gui.needsofnature.player_request.role_option", + new Object[]{candidate.requester_role(), Text.translatable("gui.needsofnature.player_request.role_activity." + candidate.requester_activity())} + ); + ButtonWidget button = ButtonWidget.builder(label, ignored -> this.parent.choose(candidate)) + .dimensions(this.width / 2 - buttonWidth / 2, startY + index * 24, buttonWidth, 20) + .build(); + button.active = candidate.selectable(); + if (!candidate.selectable()) { + button.setTooltip(NonPlayerInteractionClient.blockRequirementTooltip(candidate.block_requirement())); + } + + this.addDrawableChild(button); + } + + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.back"), ignored -> this.close()).dimensions(this.width / 2 - 75, this.height - 28, 150, 20).build() + ); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, -1); + context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(this.group.displayName()), this.width / 2, 30, -5592406); + } + + public void close() { + if (!this.resolved) { + MinecraftClient.getInstance().setScreen(this.parent); + } + } + + @Override + public UUID requestId() { + return this.requestId; + } + + @Override + public void markResolved() { + this.resolved = true; + this.parent.markResolved(); + } + } + + private static final class WaitingScreen extends Screen implements NonPlayerInteractionClient.RequestBoundScreen { + private final UUID requestId; + private boolean resolved; + + private WaitingScreen(UUID requestId) { + super(Text.translatable("gui.needsofnature.player_request.waiting_title")); + this.requestId = requestId; + } + + protected void init() { + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.cancel"), ignored -> this.cancel()) + .dimensions(this.width / 2 - 75, this.height / 2 + 24, 150, 20) + .build() + ); + } + + private void cancel() { + if (!this.resolved && ClientPlayNetworking.canSend(PlayerAnimationCancelC2SPayload.ID)) { + ClientPlayNetworking.send(new PlayerAnimationCancelC2SPayload(this.requestId)); + } + + this.resolved = true; + MinecraftClient.getInstance().setScreen(null); + } + + public void close() { + this.cancel(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.height / 2 - 18, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("gui.needsofnature.player_request.waiting_description"), this.width / 2, this.height / 2, -5592406 + ); + } + + @Override + public UUID requestId() { + return this.requestId; + } + + @Override + public void markResolved() { + this.resolved = true; + } + } +} diff --git a/src/main/java/com/nonid/client/NonPregnancyClientState.java b/src/main/java/com/nonid/client/NonPregnancyClientState.java new file mode 100644 index 0000000..6d01b1c --- /dev/null +++ b/src/main/java/com/nonid/client/NonPregnancyClientState.java @@ -0,0 +1,50 @@ +package com.nonid.client; + +import com.nonid.PregnancyCyclePhase; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonPregnancyClientState { + @Nullable + private static Identifier pregnantEntityTypeId; + private static boolean cycleEnabled; + private static int cycleDay; + private static PregnancyCyclePhase cyclePhase = PregnancyCyclePhase.NORMAL; + + private NonPregnancyClientState() { + } + + public static void setPregnantEntityTypeId(@Nullable Identifier entityTypeId) { + pregnantEntityTypeId = entityTypeId; + } + + @Nullable + public static Identifier getPregnantEntityTypeId() { + return pregnantEntityTypeId; + } + + public static void setCycleState(boolean enabled, int day, PregnancyCyclePhase phase) { + cycleEnabled = enabled; + cycleDay = enabled ? Math.max(1, Math.min(30, day)) : 0; + cyclePhase = enabled && phase != null ? phase : PregnancyCyclePhase.NORMAL; + } + + public static boolean isCycleEnabled() { + return cycleEnabled; + } + + public static int getCycleDay() { + return cycleDay; + } + + public static PregnancyCyclePhase getCyclePhase() { + return cyclePhase; + } + + public static void clear() { + pregnantEntityTypeId = null; + cycleEnabled = false; + cycleDay = 0; + cyclePhase = PregnancyCyclePhase.NORMAL; + } +} diff --git a/src/main/java/com/nonid/client/NonPregnantEffectTextures.java b/src/main/java/com/nonid/client/NonPregnantEffectTextures.java new file mode 100644 index 0000000..8f51b3f --- /dev/null +++ b/src/main/java/com/nonid/client/NonPregnantEffectTextures.java @@ -0,0 +1,44 @@ +package com.nonid.client; + +import com.nonid.PregnancyHolder; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.EntityType; +import net.minecraft.item.ItemStack; +import net.minecraft.item.SpawnEggItem; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonPregnantEffectTextures { + private static final Identifier PREGNANT_SPRITE_ID = Identifier.of("needsofnature", "mob_effect/pregnant"); + private static final int ITEM_ICON_SIZE = 16; + + private NonPregnantEffectTextures() { + } + + public static boolean shouldDrawSourceEgg(Identifier sprite) { + return PREGNANT_SPRITE_ID.equals(sprite) && !NonPregnancyClientState.isCycleEnabled(); + } + + public static ItemStack resolveSourceEgg(@Nullable ClientPlayerEntity player) { + Identifier entityTypeId = NonPregnancyClientState.getPregnantEntityTypeId(); + if (entityTypeId == null && player instanceof PregnancyHolder holder) { + entityTypeId = holder.getPregnantEntityTypeId(); + } + + if (entityTypeId == null) { + return ItemStack.EMPTY; + } + + EntityType entityType = (EntityType)Registries.ENTITY_TYPE.getOptionalValue(entityTypeId).orElse(null); + SpawnEggItem egg = entityType == null ? null : SpawnEggItem.forEntity(entityType); + return egg == null ? ItemStack.EMPTY : new ItemStack(egg); + } + + public static void drawSourceEgg(DrawContext context, ItemStack eggStack, int x, int y, int width, int height) { + int drawX = x + Math.max(0, (width - 16) / 2); + int drawY = y + Math.max(0, (height - 16) / 2); + context.drawItemWithoutEntity(eggStack, drawX, drawY); + } +} diff --git a/src/main/java/com/nonid/client/NonQuickSkinCompat.java b/src/main/java/com/nonid/client/NonQuickSkinCompat.java new file mode 100644 index 0000000..0edb23a --- /dev/null +++ b/src/main/java/com/nonid/client/NonQuickSkinCompat.java @@ -0,0 +1,117 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import java.lang.reflect.Method; +import java.util.UUID; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonQuickSkinCompat { + private static final String MOD_ID = "quickskin"; + private static boolean initialized; + private static boolean failed; + private static Method appearanceServiceGetInstance; + private static Method getAppearance; + private static Method getSkinId; + private static Method assetManagerGetInstance; + private static Method getMetadata; + private static Method getFriendlyName; + + private NonQuickSkinCompat() { + } + + @Nullable + static NonQuickSkinCompat.SkinSelection getSelectedSkin(UUID playerUuid) { + if (playerUuid != null && initialize()) { + try { + Object appearanceService = appearanceServiceGetInstance.invoke(null); + Object appearance = getAppearance.invoke(appearanceService, playerUuid); + if (appearance == null) { + return null; + } + + String skinId = (String)getSkinId.invoke(appearance); + String assetHash = assetHash(skinId); + if (assetHash == null) { + return null; + } + + Identifier textureId = Identifier.tryParse(skinId); + if (textureId == null) { + return null; + } + + Object assetManager = assetManagerGetInstance.invoke(null); + Object metadata = getMetadata.invoke(assetManager, assetHash); + if (metadata == null) { + return null; + } + + String friendlyName = (String)getFriendlyName.invoke(metadata); + return friendlyName != null && !friendlyName.isBlank() + ? new NonQuickSkinCompat.SkinSelection(textureId, skinId, friendlyName, NonSkinFolderNames.sanitize(friendlyName)) + : null; + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + fail(e); + return null; + } + } else { + return null; + } + } + + private static boolean initialize() { + if (initialized) { + return !failed; + } + + initialized = true; + if (!FabricLoader.getInstance().isModLoaded("quickskin")) { + failed = true; + return false; + } + + try { + ClassLoader loader = NonQuickSkinCompat.class.getClassLoader(); + Class appearanceServiceClass = Class.forName("com.quickskin.mod.client.services.PlayerAppearanceService", false, loader); + Class appearanceClass = Class.forName("com.quickskin.mod.common.data.PlayerAppearance", false, loader); + Class assetManagerClass = Class.forName("com.quickskin.mod.client.services.LocalAssetManager", false, loader); + Class metadataClass = Class.forName("com.quickskin.mod.common.data.AssetMetadata", false, loader); + appearanceServiceGetInstance = appearanceServiceClass.getMethod("getInstance"); + getAppearance = appearanceServiceClass.getMethod("getAppearance", UUID.class); + getSkinId = appearanceClass.getMethod("getSkinId"); + assetManagerGetInstance = assetManagerClass.getMethod("getInstance"); + getMetadata = assetManagerClass.getMethod("getMetadata", String.class); + getFriendlyName = metadataClass.getMethod("friendlyName"); + return true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + fail(e); + return false; + } + } + + @Nullable + private static String assetHash(@Nullable String skinId) { + if (skinId != null && !skinId.isBlank()) { + int separator = skinId.lastIndexOf(58); + String hash = separator >= 0 ? skinId.substring(separator + 1) : skinId; + return hash.isBlank() ? null : hash; + } else { + return null; + } + } + + private static void fail(Throwable error) { + if (!failed) { + failed = true; + NeedsOfNature.LOGGER.warn("[NoN] Quick Skin name integration is unavailable; using the global ripped-skin configuration.", error); + } + } + + record SkinSelection(Identifier textureId, String skinId, String friendlyName, String folderName) { + String cacheKey() { + return this.skinId + "\u0000" + this.folderName; + } + } +} diff --git a/src/main/java/com/nonid/client/NonServerSettingsClient.java b/src/main/java/com/nonid/client/NonServerSettingsClient.java new file mode 100644 index 0000000..d6a0b12 --- /dev/null +++ b/src/main/java/com/nonid/client/NonServerSettingsClient.java @@ -0,0 +1,166 @@ +package com.nonid.client; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.network.AnimationCatalogData; +import com.nonid.network.HostConfigSyncData; +import com.nonid.network.ServerConfigRequestC2SPayload; +import com.nonid.network.ServerConfigResultS2CPayload; +import com.nonid.network.ServerConfigUpdateC2SPayload; +import java.util.Arrays; +import java.util.List; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +public final class NonServerSettingsClient { + private static final Gson GSON = new Gson(); + private static volatile boolean canEdit; + private static volatile long configRevision = -1L; + private static volatile NonServerSettingsClient.HostConfigSnapshot editableSnapshot; + private static volatile long catalogRevision = -1L; + private static volatile List animationCatalog = List.of(); + private static NonServerSettingsClient.CatalogTransfer catalogTransfer; + + private NonServerSettingsClient() { + } + + public static void applyConfigSnapshot(String editableJson, long revision, boolean editable) { + if (revision >= configRevision) { + try { + HostConfigSyncData data = (HostConfigSyncData)GSON.fromJson(editableJson, HostConfigSyncData.class); + if (data == null) { + return; + } + + editableSnapshot = new NonServerSettingsClient.HostConfigSnapshot(data); + configRevision = revision; + canEdit = editable; + } catch (JsonParseException | IllegalStateException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to parse editable server config snapshot.", e); + } + } + } + + public static NonConfig createServerConfigDraft() { + NonConfig draft = NeedsOfNature.getLocalConfig().copy(); + NonServerSettingsClient.HostConfigSnapshot snapshot = editableSnapshot; + if (snapshot != null) { + snapshot.data().applyEditableTo(draft); + } + + return draft; + } + + public static boolean canEdit() { + return canEdit; + } + + public static long configRevision() { + return configRevision; + } + + public static boolean hasConfigSnapshot() { + return editableSnapshot != null; + } + + public static void requestRefresh() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.getNetworkHandler() != null) { + ClientPlayNetworking.send(new ServerConfigRequestC2SPayload()); + } + } + + public static void sendConfigUpdate(NonConfig config, long expectedRevision) { + if (config != null && canEdit) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.getNetworkHandler() != null) { + String json = GSON.toJson(HostConfigSyncData.fromEditableConfig(config)); + ClientPlayNetworking.send(new ServerConfigUpdateC2SPayload(expectedRevision, json)); + } + } + } + + public static void acceptCatalogChunk(long revision, int index, int count, String data) { + if (index >= 0 && count > 0 && index < count && count <= 4096 && data != null) { + if (revision >= catalogRevision) { + if (index == 0) { + catalogTransfer = new NonServerSettingsClient.CatalogTransfer(revision, new String[count]); + } else if (catalogTransfer == null || catalogTransfer.revision != revision || catalogTransfer.chunks.length != count) { + return; + } + + NonServerSettingsClient.CatalogTransfer transfer = catalogTransfer; + if (transfer != null && transfer.revision == revision && transfer.chunks.length == count) { + transfer.chunks[index] = data; + + for (String chunk : transfer.chunks) { + if (chunk == null) { + return; + } + } + + try { + StringBuilder joined = new StringBuilder(); + Arrays.stream(transfer.chunks).forEach(joined::append); + AnimationCatalogData catalog = (AnimationCatalogData)GSON.fromJson(joined.toString(), AnimationCatalogData.class); + animationCatalog = catalog != null && catalog.animations() != null ? List.copyOf(catalog.animations()) : List.of(); + catalogRevision = revision; + } catch (JsonParseException | IllegalStateException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to parse server animation catalog.", e); + } finally { + catalogTransfer = null; + } + } + } + } + } + + public static boolean hasAnimationCatalog() { + return catalogRevision >= 0L; + } + + public static long catalogRevision() { + return catalogRevision; + } + + public static List animationCatalogSnapshot() { + return animationCatalog; + } + + public static void handleConfigResult(ServerConfigResultS2CPayload payload) { + if (payload != null) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.inGameHud != null) { + String key = payload.messageKey(); + if (key != null && !key.isBlank()) { + client.inGameHud.setOverlayMessage(Text.translatable(key), false); + } + } + } + } + + public static void clear() { + canEdit = false; + configRevision = -1L; + editableSnapshot = null; + catalogRevision = -1L; + animationCatalog = List.of(); + catalogTransfer = null; + } + + private static final class CatalogTransfer { + private final long revision; + private final String[] chunks; + + private CatalogTransfer(long revision, String[] chunks) { + this.revision = revision; + this.chunks = chunks; + } + } + + private record HostConfigSnapshot(HostConfigSyncData data) { + } +} diff --git a/src/main/java/com/nonid/client/NonSkinFolderNames.java b/src/main/java/com/nonid/client/NonSkinFolderNames.java new file mode 100644 index 0000000..ad30b57 --- /dev/null +++ b/src/main/java/com/nonid/client/NonSkinFolderNames.java @@ -0,0 +1,65 @@ +package com.nonid.client; + +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.Locale; +import java.util.Set; + +final class NonSkinFolderNames { + private static final Set WINDOWS_RESERVED_NAMES = Set.of( + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" + ); + + private NonSkinFolderNames() { + } + + static String sanitize(String friendlyName) { + String normalized = Normalizer.normalize(friendlyName, Form.NFKD).toLowerCase(Locale.ROOT); + StringBuilder result = new StringBuilder(Math.min(normalized.length(), 64)); + boolean previousSeparator = false; + int offset = 0; + + while (offset < normalized.length() && result.length() < 64) { + int codePoint = normalized.codePointAt(offset); + offset += Character.charCount(codePoint); + if (Character.getType(codePoint) != 6) { + boolean allowed = codePoint >= 97 && codePoint <= 122 || codePoint >= 48 && codePoint <= 57 || codePoint == 45; + if (allowed) { + result.appendCodePoint(codePoint); + previousSeparator = false; + } else if (!previousSeparator && !result.isEmpty()) { + result.append('_'); + previousSeparator = true; + } + } + } + + while (!result.isEmpty() && result.charAt(result.length() - 1) == '_') { + result.setLength(result.length() - 1); + } + + String folderName = result.isEmpty() ? "skin" : result.toString(); + return WINDOWS_RESERVED_NAMES.contains(folderName) ? "skin_" + folderName : folderName; + } +} diff --git a/src/main/java/com/nonid/client/NonSkinOverrideCompat.java b/src/main/java/com/nonid/client/NonSkinOverrideCompat.java new file mode 100644 index 0000000..a9279f2 --- /dev/null +++ b/src/main/java/com/nonid/client/NonSkinOverrideCompat.java @@ -0,0 +1,175 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonSkinOverrideCompat { + private static final String MOD_ID = "skin_overrides"; + private static boolean initialized; + private static boolean failed; + private static Object skinOverrideManager; + private static Constructor partialProfileConstructor; + private static Method getOverride; + private static Method getCacheVersion; + private static Method getTexture; + private static Method getTexturePath; + private static Method getPlayerIdent; + private static Class libraryOverrideClass; + private static Method getLibraryEntry; + private static Field libraryEntryFile; + private static Method getEntryName; + private static Method getEntryId; + + private NonSkinOverrideCompat() { + } + + @Nullable + static NonSkinOverrideCompat.SkinSelection getSelectedSkin(String playerName, UUID playerUuid) { + if (playerName != null && !playerName.isBlank() && playerUuid != null && initialize()) { + try { + Object profile = partialProfileConstructor.newInstance(playerName, playerUuid); + if (getOverride.invoke(skinOverrideManager, profile) instanceof Optional optional && !optional.isEmpty()) { + Object override = optional.get(); + if (getTexture.invoke(override) instanceof Identifier textureId) { + Path sourcePath = null; + String friendlyName = null; + String selectionId = String.valueOf(getPlayerIdent.invoke(override)); + if (libraryOverrideClass.isInstance(override)) { + Object entry = getLibraryEntry.invoke(override); + if (libraryEntryFile.get(entry) instanceof File file) { + sourcePath = file.toPath().toAbsolutePath().normalize(); + } + + Object nameValue = getEntryName.invoke(entry); + Object idValue = getEntryId.invoke(entry); + if (nameValue instanceof String name && !name.isBlank()) { + friendlyName = name; + } + + if (idValue instanceof String id && !id.isBlank()) { + selectionId = id; + } + } else if (getTexturePath.invoke(override) instanceof File file) { + sourcePath = file.toPath().toAbsolutePath().normalize(); + } + + String sourceFingerprint = sourceFingerprint(sourcePath); + return new NonSkinOverrideCompat.SkinSelection( + textureId, + selectionId, + friendlyName, + friendlyName != null ? NonSkinFolderNames.sanitize(friendlyName) : null, + sourcePath, + sourceFingerprint + ); + } else { + return null; + } + } else { + return null; + } + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + fail(e); + return null; + } + } else { + return null; + } + } + + static int cacheVersion() { + if (!initialize()) { + return -1; + } + + try { + return ((Number)getCacheVersion.invoke(null)).intValue(); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + fail(e); + return -1; + } + } + + private static boolean initialize() { + if (initialized) { + return !failed; + } + + initialized = true; + if (!FabricLoader.getInstance().isModLoaded("skin_overrides")) { + failed = true; + return false; + } + + try { + ClassLoader loader = NonSkinOverrideCompat.class.getClassLoader(); + Class overridesClass = Class.forName("net.orifu.skin_overrides.Overrides", false, loader); + Class managerClass = Class.forName("net.orifu.skin_overrides.OverrideManager", false, loader); + Class overrideClass = Class.forName("net.orifu.skin_overrides.OverrideManager$Override", false, loader); + Class profileClass = Class.forName("net.orifu.skin_overrides.profile.Profile", false, loader); + Class partialProfileClass = Class.forName("net.orifu.skin_overrides.profile.PartialProfile", false, loader); + libraryOverrideClass = Class.forName("net.orifu.skin_overrides.override.LibraryOverrider$LibraryOverride", false, loader); + Class libraryEntryClass = Class.forName("net.orifu.skin_overrides.Library$LibraryEntry", false, loader); + Class abstractLibraryEntryClass = Class.forName("net.orifu.skin_overrides.library.AbstractLibrary$AbstractLibraryEntry", false, loader); + Field skinsField = overridesClass.getField("SKINS"); + skinOverrideManager = skinsField.get(null); + partialProfileConstructor = partialProfileClass.getConstructor(String.class, UUID.class); + getOverride = managerClass.getMethod("get", profileClass); + getCacheVersion = overridesClass.getMethod("cacheVersion"); + getTexture = overrideClass.getMethod("texture"); + getTexturePath = overrideClass.getMethod("texturePath"); + getPlayerIdent = overrideClass.getMethod("playerIdent"); + getLibraryEntry = libraryOverrideClass.getMethod("entry"); + libraryEntryFile = abstractLibraryEntryClass.getDeclaredField("file"); + libraryEntryFile.setAccessible(true); + getEntryName = libraryEntryClass.getMethod("getName"); + getEntryId = libraryEntryClass.getMethod("getId"); + return true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + fail(e); + return false; + } + } + + private static String sourceFingerprint(@Nullable Path sourcePath) { + if (sourcePath == null) { + return ""; + } + + long size = -1L; + long modified = -1L; + + try { + if (Files.isRegularFile(sourcePath)) { + size = Files.size(sourcePath); + modified = Files.getLastModifiedTime(sourcePath).toMillis(); + } + } catch (RuntimeException | IOException var6) { + } + + return "path=" + sourcePath + "|size=" + size + "|modified=" + modified; + } + + private static void fail(Throwable error) { + if (!failed) { + failed = true; + NeedsOfNature.LOGGER.warn("[NoN] Skin Override integration is unavailable; using the global ripped-skin configuration.", error); + } + } + + record SkinSelection( + Identifier textureId, String selectionId, @Nullable String friendlyName, @Nullable String folderName, @Nullable Path sourcePath, String sourceFingerprint + ) { + } +} diff --git a/src/main/java/com/nonid/client/NonSoundCues.java b/src/main/java/com/nonid/client/NonSoundCues.java new file mode 100644 index 0000000..86719cf --- /dev/null +++ b/src/main/java/com/nonid/client/NonSoundCues.java @@ -0,0 +1,151 @@ +package com.nonid.client; + +import com.nonid.data.NonPeakStages; +import com.nonid.internal.animation.data.AnimationCueEffects; +import java.util.Locale; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.random.Random; + +public final class NonSoundCues { + private static final int DRY_IMPACT_COUNT = 11; + private static final int WET_IMPACT_COUNT = 16; + private static final String[] REACTIVE_DRY = new String[]{"impactdry01", "impactdry02", "impactdry03"}; + private static final String[] REACTIVE_WET_IMPACT = new String[]{"impactwet01", "impactwet02", "impactwet03"}; + private static final String[] REACTIVE_WET = new String[]{"wet04", "wet06", "wet07"}; + private static final String PEAK_REACTIVE_SOUND = "shot_in01"; + private static final String BIRTH_SOUND = "needsofnature:pop"; + + private NonSoundCues() { + } + + public static Identifier resolveSoundId(String effect, LivingEntity anchor, Identifier stageAnimationId, int stageIndex) { + if (effect != null && !effect.isBlank()) { + if (effect.indexOf(58) >= 0) { + Identifier direct = Identifier.tryParse(effect); + if (direct != null) { + if ("minecraft".equals(direct.getNamespace()) && isLocalEffectPath(direct.getPath())) { + return Identifier.of("needsofnature", direct.getPath()); + } + + return direct; + } + } + + String resolved = mapEffect(effect, anchor, stageAnimationId, stageIndex); + if (resolved == null) { + return null; + } + + if (resolved.indexOf(58) >= 0) { + Identifier directResolved = Identifier.tryParse(resolved); + if (directResolved != null) { + if ("minecraft".equals(directResolved.getNamespace()) && isLocalEffectPath(directResolved.getPath())) { + return Identifier.of("needsofnature", directResolved.getPath()); + } + + return directResolved; + } + } + + return Identifier.of("needsofnature", resolved); + } else { + return null; + } + } + + public static boolean isPeakReactiveImpactCue(String effect, Identifier stageAnimationId, int stageIndex) { + return !AnimationCueEffects.isReactiveImpact(effect) ? false : isPeakStage(stageAnimationId, stageIndex); + } + + public static boolean isPeakAnimationStage(Identifier stageAnimationId, int stageIndex) { + return isPeakStage(stageAnimationId, stageIndex); + } + + public static boolean isActionSoundId(Identifier soundId) { + return soundId != null && "needsofnature".equals(soundId.getNamespace()) && isLocalEffectPath(soundId.getPath()); + } + + private static String mapEffect(String effect, LivingEntity anchor, Identifier stageAnimationId, int stageIndex) { + if (effect.equals("impactdry")) { + return pickRandom("impactdry", 11, anchor); + } else if (effect.equals("impactwet")) { + return pickRandom("impactwet", 16, anchor); + } else if ("reactiveimpact".equalsIgnoreCase(effect)) { + return pickReactiveImpact(anchor, stageAnimationId, stageIndex); + } else if (effect.equals("birth")) { + return "needsofnature:pop"; + } else { + return !effect.startsWith("impactdry") + && !effect.startsWith("impactwet") + && !effect.startsWith("wet") + && !effect.startsWith("shot_in") + && !effect.startsWith("shot_out") + && !effect.startsWith("motion") + && !effect.startsWith("retract") + ? null + : effect; + } + } + + private static String pickRandom(String prefix, int count, LivingEntity anchor) { + Random random = anchor != null ? anchor.getEntityWorld().getRandom() : Random.create(); + int index = random.nextInt(count) + 1; + return prefix + String.format(Locale.ROOT, "%02d", index); + } + + private static String pickReactiveImpact(LivingEntity anchor, Identifier stageAnimationId, int stageIndex) { + if (isPeakStage(stageAnimationId, stageIndex)) { + return "shot_in01"; + } else { + float ratio = NonHudOverlay.getLiquidFillRatio(); + if (ratio < 0.1F) { + return pickFromList(REACTIVE_DRY, anchor); + } else { + return ratio > 0.8F ? pickFromList(REACTIVE_WET, anchor) : pickFromList(REACTIVE_WET_IMPACT, anchor); + } + } + } + + private static boolean isPeakStage(Identifier stageAnimationId, int stageIndex) { + if (stageAnimationId == null) { + return false; + } + + if (stageIndex < 0) { + return false; + } + + if (NonPeakStages.stageNumberFromId(stageAnimationId) == null) { + return false; + } + + Identifier baseAnimationId = NonPeakStages.baseAnimationId(stageAnimationId); + Integer peakStage = NonPeakStages.getPeakStage(baseAnimationId); + if (peakStage == null) { + return false; + } + + int logicalStage = stageIndex + 1; + return logicalStage == peakStage; + } + + private static String pickFromList(String[] options, LivingEntity anchor) { + if (options != null && options.length != 0) { + Random random = anchor != null ? anchor.getEntityWorld().getRandom() : Random.create(); + return options[random.nextInt(options.length)]; + } else { + return null; + } + } + + private static boolean isLocalEffectPath(String path) { + return path.startsWith("impactdry") + || path.startsWith("impactwet") + || path.startsWith("wet") + || path.startsWith("shot_in") + || path.startsWith("shot_out") + || path.startsWith("motion") + || path.startsWith("retract"); + } +} diff --git a/src/main/java/com/nonid/client/NonSpinModelLock.java b/src/main/java/com/nonid/client/NonSpinModelLock.java new file mode 100644 index 0000000..c9e6235 --- /dev/null +++ b/src/main/java/com/nonid/client/NonSpinModelLock.java @@ -0,0 +1,114 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; + +public final class NonSpinModelLock { + private static final Set LOCKED = new HashSet<>(); + private static boolean initialized = false; + private static boolean available = false; + private static Method emfEntityOfMethod; + private static Method lockMethod; + private static Method unlockMethod; + + private NonSpinModelLock() { + } + + public static void clientTick(MinecraftClient client) { + if (client != null) { + ClientWorld world = client.world; + if (world == null) { + LOCKED.clear(); + } else if (!NonDebugSpinMode.isEnabled()) { + releaseAll(world); + } else if (ensureApi()) { + Set seen = new HashSet<>(); + + for (Entity entity : world.getEntities()) { + if (entity instanceof LivingEntity living) { + UUID uuid = living.getUuid(); + seen.add(uuid); + if (LOCKED.add(uuid)) { + lockEntity(living); + } + } + } + + LOCKED.removeIf(uuidx -> !seen.contains(uuidx)); + } + } + } + + private static void releaseAll(ClientWorld world) { + if (!LOCKED.isEmpty()) { + if (!ensureApi()) { + LOCKED.clear(); + } else { + for (UUID uuid : Set.copyOf(LOCKED)) { + if (world.getEntity(uuid) instanceof LivingEntity living) { + unlockEntity(living); + } + } + + LOCKED.clear(); + } + } + } + + private static void lockEntity(LivingEntity entity) { + invokeLockMethod(entity, lockMethod); + } + + private static void unlockEntity(LivingEntity entity) { + invokeLockMethod(entity, unlockMethod); + } + + private static void invokeLockMethod(LivingEntity entity, Method method) { + if (entity != null && method != null && emfEntityOfMethod != null) { + try { + Object emfEntity = emfEntityOfMethod.invoke(null, entity); + if (emfEntity != null) { + method.invoke(null, emfEntity); + } + } catch (ReflectiveOperationException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to invoke EMF spin lock integration; disabling it.", e); + disableApi(); + } + } + } + + private static boolean ensureApi() { + if (initialized) { + return available; + } + + initialized = true; + + try { + Class apiClass = Class.forName("traben.entity_model_features.EMFAnimationApi"); + Class emfEntityClass = Class.forName("traben.entity_model_features.utils.EMFEntity"); + emfEntityOfMethod = apiClass.getMethod("emfEntityOf", Entity.class); + lockMethod = apiClass.getMethod("lockEntityToVanillaModel", emfEntityClass); + unlockMethod = apiClass.getMethod("unlockEntityToVanillaModel", emfEntityClass); + available = true; + } catch (ClassNotFoundException | NoSuchMethodException e) { + disableApi(); + } + + return available; + } + + private static void disableApi() { + available = false; + emfEntityOfMethod = null; + lockMethod = null; + unlockMethod = null; + } +} diff --git a/src/main/java/com/nonid/client/NonStatLabelResolver.java b/src/main/java/com/nonid/client/NonStatLabelResolver.java new file mode 100644 index 0000000..5350666 --- /dev/null +++ b/src/main/java/com/nonid/client/NonStatLabelResolver.java @@ -0,0 +1,105 @@ +package com.nonid.client; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.entity.EntityType; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; + +public final class NonStatLabelResolver { + private static final String STAT_PREFIX = "stat.needsofnature."; + private static final String PEAK_ENTITY_PREFIX = "stat.needsofnature.peaked_animations_entity_"; + private static final String LIQUID_ENTITY_PREFIX = "stat.needsofnature.liquid_gained_ml_entity_"; + private static final String OFFSPRING_ENTITY_PREFIX = "stat.needsofnature.offspring_spawned_entity_"; + private static final Map ENTITY_NAME_CACHE = new ConcurrentHashMap<>(); + + private NonStatLabelResolver() { + } + + public static void clearCache() { + ENTITY_NAME_CACHE.clear(); + } + + public static String resolve(String key) { + if (key == null || key.isBlank()) { + return null; + } else if (key.startsWith("stat.needsofnature.peaked_animations_entity_")) { + String suffix = key.substring("stat.needsofnature.peaked_animations_entity_".length()); + String entityName = resolveEntityNameFromSuffix(suffix); + return "NoN: Peaked Animations (" + entityName + ")"; + } else if (key.startsWith("stat.needsofnature.liquid_gained_ml_entity_")) { + String suffix = key.substring("stat.needsofnature.liquid_gained_ml_entity_".length()); + String entityName = resolveEntityNameFromSuffix(suffix); + return "NoN: Liquid Gained (ml) (" + entityName + ")"; + } else if (key.startsWith("stat.needsofnature.offspring_spawned_entity_")) { + String suffix = key.substring("stat.needsofnature.offspring_spawned_entity_".length()); + String entityName = resolveEntityNameFromSuffix(suffix); + return "NoN: Offspring Spawned (" + entityName + ")"; + } else { + return null; + } + } + + private static String resolveEntityNameFromSuffix(String suffix) { + return suffix != null && !suffix.isBlank() ? ENTITY_NAME_CACHE.computeIfAbsent(suffix, NonStatLabelResolver::lookupEntityName) : "Unknown"; + } + + private static String lookupEntityName(String suffix) { + for (Identifier entityId : Registries.ENTITY_TYPE.getIds()) { + if (suffix.equals(sanitizeEntityId(entityId))) { + EntityType type = (EntityType)Registries.ENTITY_TYPE.get(entityId); + return type.getName().getString(); + } + } + + return prettifySuffix(suffix); + } + + private static String sanitizeEntityId(Identifier entityId) { + return sanitizePathPart(entityId.getNamespace()) + "_" + sanitizePathPart(entityId.getPath()); + } + + private static String sanitizePathPart(String value) { + if (value != null && !value.isBlank()) { + StringBuilder out = new StringBuilder(value.length()); + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c >= 'a' && c <= 'z') { + out.append(c); + } else if (c >= '0' && c <= '9') { + out.append(c); + } else { + out.append('_'); + } + } + + return out.isEmpty() ? "unknown" : out.toString(); + } else { + return "unknown"; + } + } + + private static String prettifySuffix(String suffix) { + String normalized = suffix.replace('_', ' ').trim(); + if (normalized.isEmpty()) { + return "Unknown"; + } + + StringBuilder out = new StringBuilder(normalized.length()); + boolean cap = true; + + for (int i = 0; i < normalized.length(); i++) { + char c = normalized.charAt(i); + if (cap && c >= 'a' && c <= 'z') { + out.append((char)(c - ' ')); + } else { + out.append(c); + } + + cap = c == ' '; + } + + return out.toString(); + } +} diff --git a/src/main/java/com/nonid/client/NonTagLocalizer.java b/src/main/java/com/nonid/client/NonTagLocalizer.java new file mode 100644 index 0000000..99491e9 --- /dev/null +++ b/src/main/java/com/nonid/client/NonTagLocalizer.java @@ -0,0 +1,44 @@ +package com.nonid.client; + +import java.util.Map; + +/** + * 将 afw_animdefs 的 content_tags 本地化为中文显示。 + */ +public final class NonTagLocalizer { + private static final Map TAGS = Map.ofEntries( + Map.entry("against_block", "靠方块"), + Map.entry("against_wall", "靠墙"), + Map.entry("blowjob", "口部侍奉"), + Map.entry("cowgirl", "骑乘位"), + Map.entry("doggy", "后入式"), + Map.entry("forcing", "强制"), + Map.entry("from_behind", "背后"), + Map.entry("fullnelson", "背后锁抱"), + Map.entry("handjob", "手部侍奉"), + Map.entry("holding", "抱持"), + Map.entry("mating_press", "交配压制"), + Map.entry("missionary", "传教士式"), + Map.entry("no_pen", "无插入"), + Map.entry("on_a_block", "方块上"), + Map.entry("on_a_wall", "靠墙"), + Map.entry("on_back", "仰躺"), + Map.entry("on_belly", "趴伏"), + Map.entry("oral", "口部"), + Map.entry("side", "侧位"), + Map.entry("spitroast", "双人串烧"), + Map.entry("stand_and_carry", "站立抱姿"), + Map.entry("standing", "站立"), + Map.entry("wall", "靠墙") + ); + + private NonTagLocalizer() { + } + + public static String localize(String tag) { + if (tag == null) { + return ""; + } + return TAGS.getOrDefault(tag, tag); + } +} diff --git a/src/main/java/com/nonid/client/NonWildfireGenderScreenHooks.java b/src/main/java/com/nonid/client/NonWildfireGenderScreenHooks.java new file mode 100644 index 0000000..dd41a65 --- /dev/null +++ b/src/main/java/com/nonid/client/NonWildfireGenderScreenHooks.java @@ -0,0 +1,100 @@ +package com.nonid.client; + +import com.nonid.NeedsOfNature; +import com.nonid.integration.NonModMenuExternalScreens; +import java.time.Duration; +import java.util.List; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AfterInit; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.text.Text; + +public final class NonWildfireGenderScreenHooks { + private static final String WARDROBE_SCREEN_CLASS = "com.wildfire.gui.screen.WardrobeBrowserScreen"; + private static final int GENDER_BUTTON_WIDTH = 80; + private static final int GENDER_BUTTON_HEIGHT = 15; + private static final int PERSONALIZATION_BUTTON_WIDTH = 157; + private static final int PERSONALIZATION_BUTTON_HEIGHT = 20; + private static final int REDIRECT_BUTTON_WIDTH = 176; + private static final int REDIRECT_BUTTON_VERTICAL_GAP = 3; + + private NonWildfireGenderScreenHooks() { + } + + public static void register() { + if (NonWildfireGenderSync.isAvailable()) { + ScreenEvents.AFTER_INIT.register((AfterInit)(client, screen, scaledWidth, scaledHeight) -> { + if (isWardrobeScreen(screen)) { + applyLocks(screen, scaledWidth, scaledHeight, Screens.getButtons(screen)); + } + }); + } + } + + private static void applyLocks(Screen screen, int width, int height, List widgets) { + boolean lockGender = NonWildfireGenderSync.shouldLockWildfireGenderControls(NeedsOfNature.getConfig()); + boolean lockPersonalization = NonWildfireGenderSync.shouldLockWildfirePersonalizationControls(NeedsOfNature.getConfig()); + if (lockGender || lockPersonalization) { + ClickableWidget genderButton = lockGender ? findGenderButton(widgets, width, height) : null; + if (genderButton != null) { + disableWithTooltip(genderButton, "config.needsofnature.tooltip.wildfire_gender_controlled_by_non"); + } + + if (lockGender) { + int x = genderButton != null ? genderButton.getX() : width / 2 - 130; + int y = (genderButton != null ? genderButton.getY() : height / 2 + 33) + 15 + 3; + ButtonWidget redirectButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.open_non_player_preferences"), button -> NonModMenuExternalScreens.openPlayerPreferences(screen) + ) + .dimensions(x, y, 176, 15) + .build(); + redirectButton.setTooltip(Tooltip.of(Text.translatable("config.needsofnature.tooltip.open_non_player_preferences"))); + redirectButton.setTooltipDelay(Duration.ofMillis(250L)); + widgets.add(redirectButton); + } + + ClickableWidget personalizationButton = lockPersonalization ? findPersonalizationButton(widgets, width, height) : null; + if (personalizationButton != null) { + disableWithTooltip(personalizationButton, "config.needsofnature.tooltip.wildfire_personalization_controlled_by_non"); + } + } + } + + private static ClickableWidget findGenderButton(List widgets, int width, int height) { + return findButton(widgets, width / 2 - 130, height / 2 + 33, 80, 15); + } + + private static ClickableWidget findPersonalizationButton(List widgets, int width, int height) { + return findButton(widgets, width / 2 - 36, height / 2 - 63, 157, 20); + } + + private static ClickableWidget findButton(List widgets, int x, int y, int width, int height) { + for (ClickableWidget widget : widgets) { + if (widget.getX() == x && widget.getY() == y && widget.getWidth() == width && widget.getHeight() == height) { + return widget; + } + } + + return null; + } + + private static void disableWithTooltip(ClickableWidget widget, String tooltipKey) { + widget.active = false; + widget.setTooltip(Tooltip.of(Text.translatable(tooltipKey))); + widget.setTooltipDelay(Duration.ofMillis(250L)); + } + + private static boolean isWardrobeScreen(Screen screen) { + for (Class type = screen.getClass(); type != null; type = type.getSuperclass()) { + if ("com.wildfire.gui.screen.WardrobeBrowserScreen".equals(type.getName())) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/nonid/client/NonWildfireGenderSync.java b/src/main/java/com/nonid/client/NonWildfireGenderSync.java new file mode 100644 index 0000000..eb86a28 --- /dev/null +++ b/src/main/java/com/nonid/client/NonWildfireGenderSync.java @@ -0,0 +1,630 @@ +package com.nonid.client; + +import com.nonid.GenderHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.integration.NonWildfireGenderConfigCompat; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.UUID; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; + +public final class NonWildfireGenderSync { + private static final String MOD_ID = "wildfire_gender"; + private static final float WILDFIRE_NATIVE_BREAST_HEIGHT_MIN = -1.0F; + private static final int RECONCILIATION_RETRY_INTERVAL_TICKS = 5; + private static final int RECONCILIATION_MAX_ATTEMPTS = 40; + private static final int RECONCILIATION_VERIFY_DELAY_TICKS = 20; + private static boolean initialized; + private static boolean failed; + private static boolean loggedFailure; + private static boolean breastTextureEditorInitialized; + private static boolean loggedBreastTextureEditorFailure; + private static Method getPlayerByIdMethod; + private static Method updateGenderMethod; + private static Method updateBustSizeMethod; + private static Method updateBreastPhysicsMethod; + private static Method updateBounceMultiplierMethod; + private static Method updateFloppinessMethod; + private static Method getBreastsMethod; + private static Method updateXOffsetMethod; + private static Method updateYOffsetMethod; + private static Method updateZOffsetMethod; + private static Method updateCleavageMethod; + private static Method updateUniboobMethod; + private static Method getBustSizeMethod; + private static Method hasBreastPhysicsMethod; + private static Method getBounceMultiplierMethod; + private static Method getFloppinessMethod; + private static Method getXOffsetMethod; + private static Method getYOffsetMethod; + private static Method getZOffsetMethod; + private static Method getCleavageMethod; + private static Method isUniboobMethod; + private static Method getLeftBreastPhysicsMethod; + private static Method getRightBreastPhysicsMethod; + private static Method renderPlayerInFrameMethod; + private static Method saveMethod; + private static Field bustSizeField; + private static Field breastPhysicsField; + private static Field bounceMultiplierField; + private static Field floppyMultiplierField; + private static Field xOffsetField; + private static Field yOffsetField; + private static Field zOffsetField; + private static Field cleavageField; + private static Field uniboobField; + private static Field physicsBreastSizeField; + private static Field physicsPreBreastSizeField; + private static Constructor breastCustomizationScreenConstructor; + private static Constructor breastTextureEditorScreenConstructor; + private static Object wildfireMaleGender; + private static Object wildfireFemaleGender; + private static Object wildfireOtherGender; + private static NonWildfireGenderSync.ReconciliationPhase reconciliationPhase = NonWildfireGenderSync.ReconciliationPhase.IDLE; + private static UUID reconciliationPlayerUuid; + private static int reconciliationDelayTicks; + private static int reconciliationAttemptsRemaining; + + private NonWildfireGenderSync() { + } + + public static boolean isAvailable() { + return FabricLoader.getInstance().isModLoaded("wildfire_gender"); + } + + public static boolean shouldLockWildfireGenderControls(NonConfig config) { + return config != null && config.isSyncFemaleGenderModWithNonGender() && isAvailable(); + } + + public static boolean shouldLockWildfirePersonalizationControls(NonConfig config) { + return config != null && config.isDestroyedSkinSystemEnabled() && config.isDestroyedSkinFemaleGenderOverridesEnabled() && isAvailable(); + } + + public static void syncFromNonConfig(NonConfig config) { + if (config != null && config.isSyncFemaleGenderModWithNonGender() && isAvailable()) { + requestReconciliation(config); + syncFromSelection(config, config.getPlayerGenderSelection()); + } + } + + public static void syncFromMask(NonConfig config, int mask) { + if (config != null && config.isSyncFemaleGenderModWithNonGender() && isAvailable()) { + requestReconciliation(config); + syncFromSelection(config, NonConfig.PlayerGenderSelection.fromMask(mask, config.getPlayerGenderSelection())); + } + } + + public static void syncDestroyedSkinOverrides(NonConfig config) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + syncDestroyedSkinOverrides(config, NonDestroyedSkinClient.getStage(client.player.getUuid())); + } + } + + public static void syncDestroyedSkinOverrides(NonConfig config, int destroyedStage) { + if (config != null && config.isDestroyedSkinSystemEnabled() && config.isDestroyedSkinFemaleGenderOverridesEnabled() && isAvailable()) { + requestReconciliation(config); + if (init()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + NonConfig.FemaleGenderDestroyedProfile profile = destroyedStage >= 2 + ? config.getFemaleGenderDestroyedHigh() + : config.getFemaleGenderDestroyedLow(); + + try { + Object playerConfig = getPlayerByIdMethod.invoke(null, client.player.getUuid()); + if (playerConfig == null) { + return; + } + + applyProfile(playerConfig, profile, true); + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure(e); + } + } + } + } + } + + public static void onWorldJoin() { + requestReconciliation(NeedsOfNature.getLocalConfig()); + } + + public static void onWorldDisconnect() { + clearReconciliation(); + } + + private static void clearReconciliation() { + reconciliationPhase = NonWildfireGenderSync.ReconciliationPhase.IDLE; + reconciliationPlayerUuid = null; + reconciliationDelayTicks = 0; + reconciliationAttemptsRemaining = 0; + } + + public static void clientTick(MinecraftClient client) { + if (reconciliationPhase != NonWildfireGenderSync.ReconciliationPhase.IDLE && client != null && client.world != null && client.player != null) { + UUID playerUuid = client.player.getUuid(); + if (!playerUuid.equals(reconciliationPlayerUuid)) { + reconciliationPlayerUuid = playerUuid; + reconciliationPhase = NonWildfireGenderSync.ReconciliationPhase.WAITING_FOR_PROFILE; + reconciliationDelayTicks = 0; + reconciliationAttemptsRemaining = 40; + } + + if (reconciliationDelayTicks > 0) { + reconciliationDelayTicks--; + } else { + NonWildfireGenderSync.ReconciliationResult result = reconcileLocalState(client, NeedsOfNature.getLocalConfig()); + if (result == NonWildfireGenderSync.ReconciliationResult.FAILED) { + clearReconciliation(); + } else if (result == NonWildfireGenderSync.ReconciliationResult.NOT_READY) { + reconciliationAttemptsRemaining--; + if (reconciliationAttemptsRemaining <= 0) { + clearReconciliation(); + } else { + reconciliationDelayTicks = 5; + } + } else if (reconciliationPhase == NonWildfireGenderSync.ReconciliationPhase.WAITING_FOR_PROFILE) { + reconciliationPhase = NonWildfireGenderSync.ReconciliationPhase.VERIFYING; + reconciliationDelayTicks = 20; + } else { + clearReconciliation(); + } + } + } + } + + private static void requestReconciliation(NonConfig config) { + if (isAvailable() && managesWildfireState(config)) { + MinecraftClient client = MinecraftClient.getInstance(); + reconciliationPlayerUuid = client != null && client.player != null ? client.player.getUuid() : null; + reconciliationPhase = NonWildfireGenderSync.ReconciliationPhase.WAITING_FOR_PROFILE; + reconciliationDelayTicks = 0; + reconciliationAttemptsRemaining = 40; + } + } + + private static boolean managesWildfireState(NonConfig config) { + return config != null + && (config.isSyncFemaleGenderModWithNonGender() || config.isDestroyedSkinSystemEnabled() && config.isDestroyedSkinFemaleGenderOverridesEnabled()); + } + + private static NonWildfireGenderSync.ReconciliationResult reconcileLocalState(MinecraftClient client, NonConfig config) { + if (!managesWildfireState(config)) { + return NonWildfireGenderSync.ReconciliationResult.READY; + } + + if (!init()) { + return NonWildfireGenderSync.ReconciliationResult.FAILED; + } + + try { + Object playerConfig = getPlayerByIdMethod.invoke(null, client.player.getUuid()); + if (playerConfig == null) { + return NonWildfireGenderSync.ReconciliationResult.NOT_READY; + } + + boolean changed = false; + if (config.isSyncFemaleGenderModWithNonGender()) { + int mask = config.getPlayerGenderMask() & 3; + if (client.player instanceof GenderHolder holder) { + int syncedMask = holder.getGenderMask() & 3; + if (syncedMask != 0) { + mask = syncedMask; + } + } + + NonConfig.PlayerGenderSelection selection = NonConfig.PlayerGenderSelection.fromMask(mask, config.getPlayerGenderSelection()); + changed |= Boolean.TRUE.equals(updateGenderMethod.invoke(playerConfig, toWildfireGender(selection))); + } + + if (config.isDestroyedSkinSystemEnabled() && config.isDestroyedSkinFemaleGenderOverridesEnabled()) { + int destroyedStage = NonDestroyedSkinClient.getStage(client.player.getUuid()); + NonConfig.FemaleGenderDestroyedProfile profile = destroyedStage >= 2 ? config.getFemaleGenderDestroyedHigh() : config.getFemaleGenderDestroyedLow(); + changed |= updateProfile(playerConfig, profile); + } + + if (changed) { + saveMethod.invoke(playerConfig); + } + + return NonWildfireGenderSync.ReconciliationResult.READY; + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure(e); + return NonWildfireGenderSync.ReconciliationResult.FAILED; + } + } + + public static boolean openBreastCustomizationScreen(Screen parent) { + if (isAvailable() && init()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && breastCustomizationScreenConstructor != null) { + try { + if (breastCustomizationScreenConstructor.newInstance(parent, client.player.getUuid()) instanceof Screen minecraftScreen) { + client.setScreen(minecraftScreen); + return true; + } + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure(e); + } + + return false; + } else { + return false; + } + } else { + return false; + } + } + + public static boolean canOpenBreastTextureEditor() { + return isAvailable() && initBreastTextureEditor(); + } + + public static boolean openBreastTextureEditor(Screen parent) { + if (isAvailable() && initBreastTextureEditor()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && breastTextureEditorScreenConstructor != null) { + try { + if (breastTextureEditorScreenConstructor.newInstance(parent, client.player.getUuid()) instanceof Screen minecraftScreen) { + client.setScreen(minecraftScreen); + return true; + } + } catch (ReflectiveOperationException | RuntimeException e) { + logBreastTextureEditorFailure(e); + } + + return false; + } else { + return false; + } + } else { + return false; + } + } + + public static boolean renderBreastPreview( + Screen parent, DrawContext context, NonConfig.FemaleGenderDestroyedProfile profile, int centerX, int frameY, int mouseX, int mouseY + ) { + if (parent != null && context != null && profile != null && isAvailable() && init()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && breastCustomizationScreenConstructor != null && renderPlayerInFrameMethod != null) { + try { + Object playerConfig = getPlayerByIdMethod.invoke(null, client.player.getUuid()); + if (playerConfig == null) { + return false; + } + + NonConfig.FemaleGenderDestroyedProfile previous = readProfile(playerConfig); + NonWildfireGenderSync.BreastPhysicsSnapshot physicsSnapshot = snapshotBreastPhysics(playerConfig); + + try { + applyProfile(playerConfig, profile, false); + applyBreastPhysicsSize(playerConfig, profile.breastSize()); + Object screen = breastCustomizationScreenConstructor.newInstance(parent, client.player.getUuid()); + renderPlayerInFrameMethod.invoke(screen, context, centerX, frameY, mouseX, mouseY); + } finally { + applyProfile(playerConfig, previous, false); + if (physicsSnapshot != null) { + physicsSnapshot.restore(); + } + } + + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure(e); + return false; + } + } else { + return false; + } + } else { + return false; + } + } + + private static boolean applyProfile(Object playerConfig, NonConfig.FemaleGenderDestroyedProfile profile, boolean save) throws ReflectiveOperationException { + if (!save) { + applyProfileFields(playerConfig, profile); + return true; + } + + boolean changed = updateProfile(playerConfig, profile); + if (changed) { + saveMethod.invoke(playerConfig); + } + + return changed; + } + + private static boolean updateProfile(Object playerConfig, NonConfig.FemaleGenderDestroyedProfile profile) throws ReflectiveOperationException { + boolean changed = false; + changed |= applyBreastSize(playerConfig, profile.breastSize()); + changed |= Boolean.TRUE.equals(updateBreastPhysicsMethod.invoke(playerConfig, profile.breastPhysics())); + changed |= Boolean.TRUE.equals(updateBounceMultiplierMethod.invoke(playerConfig, profile.intensity())); + changed |= Boolean.TRUE.equals(updateFloppinessMethod.invoke(playerConfig, profile.momentum())); + Object breasts = getBreastsMethod.invoke(playerConfig); + if (breasts != null) { + changed |= Boolean.TRUE.equals(updateXOffsetMethod.invoke(breasts, profile.separation())); + changed |= applyBreastHeight(breasts, profile.height()); + changed |= Boolean.TRUE.equals(updateZOffsetMethod.invoke(breasts, profile.depth())); + changed |= Boolean.TRUE.equals(updateCleavageMethod.invoke(breasts, profile.rotation())); + changed |= Boolean.TRUE.equals(updateUniboobMethod.invoke(breasts, !profile.dualPhysics())); + } + + return changed; + } + + private static boolean applyBreastSize(Object playerConfig, float breastSize) throws ReflectiveOperationException { + float supportedSize = NonWildfireGenderConfigCompat.clampBreastSize(breastSize); + return Boolean.TRUE.equals(updateBustSizeMethod.invoke(playerConfig, supportedSize)); + } + + private static boolean applyBreastHeight(Object breasts, float height) throws ReflectiveOperationException { + if (height >= -1.0F) { + return Boolean.TRUE.equals(updateYOffsetMethod.invoke(breasts, height)); + } + + if (Float.compare(yOffsetField.getFloat(breasts), height) == 0) { + return false; + } + + yOffsetField.setFloat(breasts, height); + return true; + } + + private static void applyProfileFields(Object playerConfig, NonConfig.FemaleGenderDestroyedProfile profile) throws ReflectiveOperationException { + bustSizeField.setFloat(playerConfig, profile.breastSize()); + breastPhysicsField.setBoolean(playerConfig, profile.breastPhysics()); + bounceMultiplierField.setFloat(playerConfig, profile.intensity()); + floppyMultiplierField.setFloat(playerConfig, profile.momentum()); + Object breasts = getBreastsMethod.invoke(playerConfig); + if (breasts != null) { + xOffsetField.setFloat(breasts, profile.separation()); + yOffsetField.setFloat(breasts, profile.height()); + zOffsetField.setFloat(breasts, profile.depth()); + cleavageField.setFloat(breasts, profile.rotation()); + uniboobField.setBoolean(breasts, !profile.dualPhysics()); + } + } + + private static NonConfig.FemaleGenderDestroyedProfile readProfile(Object playerConfig) throws ReflectiveOperationException { + Object breasts = getBreastsMethod.invoke(playerConfig); + float rotation = 0.0F; + float height = 0.0F; + float depth = 0.0F; + float separation = 0.0F; + boolean dualPhysics = false; + if (breasts != null) { + separation = ((Number)getXOffsetMethod.invoke(breasts)).floatValue(); + height = ((Number)getYOffsetMethod.invoke(breasts)).floatValue(); + depth = ((Number)getZOffsetMethod.invoke(breasts)).floatValue(); + rotation = ((Number)getCleavageMethod.invoke(breasts)).floatValue(); + dualPhysics = !Boolean.TRUE.equals(isUniboobMethod.invoke(breasts)); + } + + return new NonConfig.FemaleGenderDestroyedProfile( + ((Number)getBustSizeMethod.invoke(playerConfig)).floatValue(), + separation, + height, + depth, + rotation, + Boolean.TRUE.equals(hasBreastPhysicsMethod.invoke(playerConfig)), + dualPhysics, + ((Number)getBounceMultiplierMethod.invoke(playerConfig)).floatValue(), + ((Number)getFloppinessMethod.invoke(playerConfig)).floatValue() + ); + } + + private static NonWildfireGenderSync.BreastPhysicsSnapshot snapshotBreastPhysics(Object playerConfig) throws ReflectiveOperationException { + Object left = getLeftBreastPhysicsMethod.invoke(playerConfig); + Object right = getRightBreastPhysicsMethod.invoke(playerConfig); + return left != null && right != null + ? new NonWildfireGenderSync.BreastPhysicsSnapshot( + left, + right, + physicsBreastSizeField.getFloat(left), + physicsPreBreastSizeField.getFloat(left), + physicsBreastSizeField.getFloat(right), + physicsPreBreastSizeField.getFloat(right) + ) + : null; + } + + private static void applyBreastPhysicsSize(Object playerConfig, float breastSize) throws ReflectiveOperationException { + setBreastPhysicsSize(getLeftBreastPhysicsMethod.invoke(playerConfig), breastSize); + setBreastPhysicsSize(getRightBreastPhysicsMethod.invoke(playerConfig), breastSize); + } + + private static void setBreastPhysicsSize(Object breastPhysics, float breastSize) throws IllegalAccessException { + if (breastPhysics != null) { + physicsBreastSizeField.setFloat(breastPhysics, breastSize); + physicsPreBreastSizeField.setFloat(breastPhysics, breastSize); + } + } + + private static void syncFromSelection(NonConfig config, NonConfig.PlayerGenderSelection selection) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + if (init()) { + try { + UUID playerUuid = client.player.getUuid(); + Object playerConfig = getPlayerByIdMethod.invoke(null, playerUuid); + if (playerConfig == null) { + return; + } + + Object targetGender = toWildfireGender(selection); + Object changed = updateGenderMethod.invoke(playerConfig, targetGender); + if (Boolean.TRUE.equals(changed)) { + saveMethod.invoke(playerConfig); + } + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure(e); + } + } + } + } + + private static Object toWildfireGender(NonConfig.PlayerGenderSelection selection) { + if (selection == NonConfig.PlayerGenderSelection.MALE) { + return wildfireMaleGender; + } else { + return selection == NonConfig.PlayerGenderSelection.BOTH ? wildfireOtherGender : wildfireFemaleGender; + } + } + + private static boolean init() { + if (initialized) { + return !failed; + } + + initialized = true; + if (!isAvailable()) { + failed = true; + return false; + } + + try { + Class apiClass = Class.forName("com.wildfire.api.WildfireAPI"); + Class playerConfigClass = Class.forName("com.wildfire.main.entitydata.PlayerConfig"); + Class entityConfigClass = Class.forName("com.wildfire.main.entitydata.EntityConfig"); + Class breastsClass = Class.forName("com.wildfire.main.entitydata.Breasts"); + Class genderClass = Class.forName("com.wildfire.main.config.enums.Gender"); + Class breastPhysicsClass = Class.forName("com.wildfire.physics.BreastPhysics"); + Class breastCustomizationScreenClass = Class.forName("com.wildfire.gui.screen.WildfireBreastCustomizationScreen"); + getPlayerByIdMethod = apiClass.getMethod("getPlayerById", UUID.class); + updateGenderMethod = playerConfigClass.getMethod("updateGender", genderClass); + updateBustSizeMethod = playerConfigClass.getMethod("updateBustSize", float.class); + updateBreastPhysicsMethod = playerConfigClass.getMethod("updateBreastPhysics", boolean.class); + updateBounceMultiplierMethod = playerConfigClass.getMethod("updateBounceMultiplier", float.class); + updateFloppinessMethod = playerConfigClass.getMethod("updateFloppiness", float.class); + getBreastsMethod = playerConfigClass.getMethod("getBreasts"); + getBustSizeMethod = playerConfigClass.getMethod("getBustSize"); + hasBreastPhysicsMethod = playerConfigClass.getMethod("hasBreastPhysics"); + getBounceMultiplierMethod = playerConfigClass.getMethod("getBounceMultiplier"); + getFloppinessMethod = playerConfigClass.getMethod("getFloppiness"); + updateXOffsetMethod = breastsClass.getMethod("updateXOffset", float.class); + updateYOffsetMethod = breastsClass.getMethod("updateYOffset", float.class); + updateZOffsetMethod = breastsClass.getMethod("updateZOffset", float.class); + updateCleavageMethod = breastsClass.getMethod("updateCleavage", float.class); + updateUniboobMethod = breastsClass.getMethod("updateUniboob", boolean.class); + getXOffsetMethod = breastsClass.getMethod("getXOffset"); + getYOffsetMethod = breastsClass.getMethod("getYOffset"); + getZOffsetMethod = breastsClass.getMethod("getZOffset"); + getCleavageMethod = breastsClass.getMethod("getCleavage"); + isUniboobMethod = breastsClass.getMethod("isUniboob"); + getLeftBreastPhysicsMethod = entityConfigClass.getMethod("getLeftBreastPhysics"); + getRightBreastPhysicsMethod = entityConfigClass.getMethod("getRightBreastPhysics"); + saveMethod = playerConfigClass.getMethod("save"); + breastCustomizationScreenConstructor = breastCustomizationScreenClass.getConstructor(Screen.class, UUID.class); + Class baseWildfireScreenClass = Class.forName("com.wildfire.gui.screen.BaseWildfireScreen"); + renderPlayerInFrameMethod = baseWildfireScreenClass.getDeclaredMethod( + "renderPlayerInFrame", DrawContext.class, int.class, int.class, int.class, int.class + ); + renderPlayerInFrameMethod.setAccessible(true); + bustSizeField = accessibleField(entityConfigClass, "pBustSize"); + breastPhysicsField = accessibleField(entityConfigClass, "breastPhysics"); + bounceMultiplierField = accessibleField(entityConfigClass, "bounceMultiplier"); + floppyMultiplierField = accessibleField(entityConfigClass, "floppyMultiplier"); + xOffsetField = accessibleField(breastsClass, "xOffset"); + yOffsetField = accessibleField(breastsClass, "yOffset"); + zOffsetField = accessibleField(breastsClass, "zOffset"); + cleavageField = accessibleField(breastsClass, "cleavage"); + uniboobField = accessibleField(breastsClass, "uniboob"); + physicsBreastSizeField = accessibleField(breastPhysicsClass, "breastSize"); + physicsPreBreastSizeField = accessibleField(breastPhysicsClass, "preBreastSize"); + wildfireMaleGender = enumConstant(genderClass, "MALE"); + wildfireFemaleGender = enumConstant(genderClass, "FEMALE"); + wildfireOtherGender = enumConstant(genderClass, "OTHER"); + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + failed = true; + logFailure(e); + return false; + } + } + + private static synchronized boolean initBreastTextureEditor() { + if (breastTextureEditorInitialized) { + return breastTextureEditorScreenConstructor != null; + } + + breastTextureEditorInitialized = true; + + try { + Class screenClass = Class.forName("com.wildfire.gui.screen.WildfireBreastUVEditorScreen"); + breastTextureEditorScreenConstructor = screenClass.getConstructor(Screen.class, UUID.class); + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + logBreastTextureEditorFailure(e); + return false; + } + } + + private static Object enumConstant(Class enumClass, String name) { + return Enum.valueOf(enumClass.asSubclass(Enum.class), name); + } + + private static Field accessibleField(Class owner, String name) throws NoSuchFieldException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private static void logFailure(Exception e) { + if (!loggedFailure) { + loggedFailure = true; + NeedsOfNature.LOGGER.warn("[NoN] Failed to integrate with Female Gender Mod.", e); + } + } + + private static void logBreastTextureEditorFailure(Exception e) { + if (!loggedBreastTextureEditorFailure) { + loggedBreastTextureEditorFailure = true; + NeedsOfNature.LOGGER.warn("[NoN] Female Gender Mod breast texture editor is unavailable.", e); + } + } + + private static final class BreastPhysicsSnapshot { + private final Object left; + private final Object right; + private final float leftSize; + private final float leftPreSize; + private final float rightSize; + private final float rightPreSize; + + private BreastPhysicsSnapshot(Object left, Object right, float leftSize, float leftPreSize, float rightSize, float rightPreSize) { + this.left = left; + this.right = right; + this.leftSize = leftSize; + this.leftPreSize = leftPreSize; + this.rightSize = rightSize; + this.rightPreSize = rightPreSize; + } + + private void restore() throws IllegalAccessException { + NonWildfireGenderSync.physicsBreastSizeField.setFloat(this.left, this.leftSize); + NonWildfireGenderSync.physicsPreBreastSizeField.setFloat(this.left, this.leftPreSize); + NonWildfireGenderSync.physicsBreastSizeField.setFloat(this.right, this.rightSize); + NonWildfireGenderSync.physicsPreBreastSizeField.setFloat(this.right, this.rightPreSize); + } + } + + private enum ReconciliationPhase { + IDLE, + WAITING_FOR_PROFILE, + VERIFYING; + } + + private enum ReconciliationResult { + READY, + NOT_READY, + FAILED; + } +} diff --git a/src/main/java/com/nonid/client/PregnancyEggGeoModel.java b/src/main/java/com/nonid/client/PregnancyEggGeoModel.java new file mode 100644 index 0000000..50855ce --- /dev/null +++ b/src/main/java/com/nonid/client/PregnancyEggGeoModel.java @@ -0,0 +1,85 @@ +package com.nonid.client; + +import com.nonid.entity.PregnancyEggEntity; +import com.nonid.internal.animation.AnimationEngine; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.MinecraftClient; +import net.minecraft.resource.ResourceManager; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.cache.GeckoLibResources; +import software.bernie.geckolib.constant.dataticket.DataTicket; +import software.bernie.geckolib.model.GeoModel; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public final class PregnancyEggGeoModel extends GeoModel { + public static final DataTicket<@NotNull Identifier> SOURCE_ENTITY_TYPE_TICKET = DataTicket.create("non_pregnancy_egg_source_entity_type", Identifier.class); + public static final DataTicket<@NotNull Float> GROWTH_SCALE_TICKET = DataTicket.create("non_pregnancy_egg_growth_scale", Float.class); + public static final DataTicket<@NotNull Float> HURT_SHAKE_TICKET = DataTicket.create("non_pregnancy_egg_hurt_shake", Float.class); + public static final DataTicket<@NotNull Identifier> EGG_TEXTURE_TICKET = DataTicket.create("non_pregnancy_egg_texture", Identifier.class); + public static final Identifier DEFAULT_MODEL_ID = Identifier.of("needsofnature", "entity/pregnancy_egg/default"); + public static final Identifier DEFAULT_TEXTURE_ID = Identifier.of("needsofnature", "textures/entity/pregnancy_egg/default.png"); + private static final Identifier PLACEHOLDER_ANIMATION_ID = AnimationEngine.packId("afw/placeholder"); + private static final Map TEXTURE_EXISTS_CACHE = new ConcurrentHashMap<>(); + + @NotNull + public Identifier getModelResource(@NotNull GeoRenderState renderState) { + Identifier specific = resolveSpecificModelId(renderState); + return specific != null && GeckoLibResources.getBakedModels().cache().containsKey(specific) ? specific : DEFAULT_MODEL_ID; + } + + @NotNull + public Identifier getTextureResource(@NotNull GeoRenderState renderState) { + Identifier configured = (Identifier)renderState.getGeckolibData(EGG_TEXTURE_TICKET); + if (configured != null) { + return hasTexture(configured) ? configured : DEFAULT_TEXTURE_ID; + } + + Identifier specific = resolveSpecificTextureId(renderState); + return specific != null && hasTexture(specific) ? specific : DEFAULT_TEXTURE_ID; + } + + @NotNull + public Identifier getAnimationResource(PregnancyEggEntity animatable) { + return PLACEHOLDER_ANIMATION_ID; + } + + private static Identifier resolveSpecificModelId(GeoRenderState renderState) { + Identifier sourceType = (Identifier)renderState.getGeckolibData(SOURCE_ENTITY_TYPE_TICKET); + return sourceType == null ? null : modelIdForSourceEntity(sourceType); + } + + private static Identifier resolveSpecificTextureId(GeoRenderState renderState) { + Identifier sourceType = (Identifier)renderState.getGeckolibData(SOURCE_ENTITY_TYPE_TICKET); + return sourceType == null ? null : Identifier.of("needsofnature", "textures/entity/pregnancy_egg/" + toFileStem(sourceType) + ".png"); + } + + static Identifier modelIdForSourceEntity(Identifier sourceType) { + return Identifier.of("needsofnature", "entity/pregnancy_egg/" + toFileStem(sourceType)); + } + + private static String toFileStem(Identifier typeId) { + String namespace = typeId.getNamespace(); + String path = typeId.getPath().replace('/', '_'); + return namespace + "_" + path; + } + + private static boolean hasTexture(Identifier textureId) { + return textureId == null ? false : TEXTURE_EXISTS_CACHE.computeIfAbsent(textureId, PregnancyEggGeoModel::findTexture); + } + + public static void clearTextureCache() { + TEXTURE_EXISTS_CACHE.clear(); + } + + private static boolean findTexture(Identifier textureId) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return false; + } + + ResourceManager manager = client.getResourceManager(); + return manager == null ? false : manager.getResource(textureId).isPresent(); + } +} diff --git a/src/main/java/com/nonid/client/PregnancyEggRenderer.java b/src/main/java/com/nonid/client/PregnancyEggRenderer.java new file mode 100644 index 0000000..c3a0a84 --- /dev/null +++ b/src/main/java/com/nonid/client/PregnancyEggRenderer.java @@ -0,0 +1,61 @@ +package com.nonid.client; + +import com.nonid.entity.PregnancyEggEntity; +import net.minecraft.client.render.OverlayTexture; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.EntityRendererFactory.Context; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.RotationAxis; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.renderer.GeoEntityRenderer; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.RenderPassInfo; + +public final class PregnancyEggRenderer extends GeoEntityRenderer { + public PregnancyEggRenderer(Context context) { + super(context, new PregnancyEggGeoModel()); + } + + public void addRenderData(PregnancyEggEntity animatable, Void relatedObject, R renderState, float partialTick) { + super.addRenderData(animatable, relatedObject, renderState, partialTick); + Identifier sourceEntityTypeId = animatable.getSourceEntityTypeId(); + if (sourceEntityTypeId != null) { + renderState.addGeckolibData(PregnancyEggGeoModel.SOURCE_ENTITY_TYPE_TICKET, sourceEntityTypeId); + } + + renderState.addGeckolibData(PregnancyEggGeoModel.GROWTH_SCALE_TICKET, animatable.getGrowthScale(partialTick)); + renderState.addGeckolibData(PregnancyEggGeoModel.HURT_SHAKE_TICKET, Math.max(0.0F, animatable.hurtTime - partialTick)); + Identifier eggTextureId = animatable.getEggTextureId(); + renderState.addGeckolibData(PregnancyEggGeoModel.EGG_TEXTURE_TICKET, eggTextureId == null ? PregnancyEggGeoModel.DEFAULT_TEXTURE_ID : eggTextureId); + } + + public void scaleModelForRender(RenderPassInfo<@NotNull R> renderPassInfo, float widthScale, float heightScale) { + float growth = (Float)renderPassInfo.getOrDefaultGeckolibData(PregnancyEggGeoModel.GROWTH_SCALE_TICKET, 1.0F); + float clamped = MathHelper.clamp(growth, 0.05F, 4.0F); + super.scaleModelForRender(renderPassInfo, widthScale * clamped, heightScale * clamped); + } + + public void preRenderPass(RenderPassInfo<@NotNull R> renderPassInfo, OrderedRenderCommandQueue renderTasks) { + super.preRenderPass(renderPassInfo, renderTasks); + float hurtShake = (Float)renderPassInfo.getOrDefaultGeckolibData(PregnancyEggGeoModel.HURT_SHAKE_TICKET, 0.0F); + if (!(hurtShake <= 0.0F)) { + float intensity = MathHelper.clamp((hurtShake - 7.0F) / 3.0F, 0.0F, 1.0F); + if (!(intensity <= 0.0F)) { + double wobble = 0.004 * intensity; + float roll = 2.0F * intensity; + renderPassInfo.poseStack().translate(wobble, 0.0, 0.0); + renderPassInfo.poseStack().multiply(RotationAxis.POSITIVE_Z.rotationDegrees(roll)); + } + } + } + + public int getPackedOverlay(PregnancyEggEntity animatable, Void relatedObject, float u, float partialTick) { + return OverlayTexture.DEFAULT_UV; + } + + protected float getDeathMaxRotation(@NotNull GeoRenderState renderState) { + return 0.0F; + } +} diff --git a/src/main/java/com/nonid/client/intiface/NonIntifaceBridge.java b/src/main/java/com/nonid/client/intiface/NonIntifaceBridge.java new file mode 100644 index 0000000..b3e4226 --- /dev/null +++ b/src/main/java/com/nonid/client/intiface/NonIntifaceBridge.java @@ -0,0 +1,191 @@ +package com.nonid.client.intiface; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import net.minecraft.text.Text; + +public final class NonIntifaceBridge { + private static final String CORE_CLASS = "io.github.blackspherefollower.buttplug4j.ButtplugException"; + private static final String CONNECTOR_CLASS = "io.github.blackspherefollower.buttplug4j.connectors.jetty.websocket.client.ButtplugClientWSClient"; + private static final String JACKSON_CORE_CLASS = "com.fasterxml.jackson.core.JsonProcessingException"; + private static final String JACKSON_DATABIND_CLASS = "com.fasterxml.jackson.databind.ObjectMapper"; + private static volatile Boolean available; + private static volatile boolean warnedUnavailable; + + private NonIntifaceBridge() { + } + + public static boolean isRuntimeAvailable() { + return isAvailable(); + } + + public static Text statusText(NonConfig config) { + if (!isUsable()) { + return Text.translatable("config.needsofnature.intiface.status.unavailable"); + } + + try { + return NonIntifaceClient.statusText(config); + } catch (LinkageError e) { + markUnavailable(e); + return Text.translatable("config.needsofnature.intiface.status.unavailable"); + } + } + + public static boolean isConnected() { + if (!isUsable()) { + return false; + } + + try { + return NonIntifaceClient.isConnected(); + } catch (LinkageError e) { + markUnavailable(e); + return false; + } + } + + public static void onWorldJoin(NonConfig config) { + if (isUsable()) { + try { + NonIntifaceClient.onWorldJoin(config); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void onWorldDisconnect(NonConfig config) { + if (isUsable()) { + try { + NonIntifaceClient.onWorldDisconnect(config); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void connect(NonConfig config) { + if (isUsable()) { + try { + NonIntifaceClient.connect(config); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void disconnect() { + if (isUsable()) { + try { + NonIntifaceClient.disconnect(); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void startScanning(NonConfig config) { + if (isUsable()) { + try { + NonIntifaceClient.startScanning(config); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void stopAll() { + if (isUsable()) { + try { + NonIntifaceClient.stopAll(); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void testPulse(NonConfig config, boolean peak) { + if (isUsable()) { + try { + NonIntifaceClient.testPulse(config, peak); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void pulseReactiveImpact(NonConfig config, double nextReactiveImpactDelayMs) { + if (isUsable()) { + try { + NonIntifaceClient.pulseReactiveImpact(config, nextReactiveImpactDelayMs); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void pulsePeakReactiveImpact(NonConfig config, double nextReactiveImpactDelayMs) { + if (isUsable()) { + try { + NonIntifaceClient.pulsePeakReactiveImpact(config, nextReactiveImpactDelayMs); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void clientTick(NonConfig config, boolean localAnimationActive, boolean peakStage, double specialVibratorScalar) { + if (isUsable()) { + try { + NonIntifaceClient.clientTick(config, localAnimationActive, peakStage, specialVibratorScalar); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + public static void clientTick( + NonConfig config, boolean localAnimationActive, boolean peakStage, double specialVibratorScalar, int energizedLevel, double energizedHeartPulse + ) { + if (isUsable()) { + try { + NonIntifaceClient.clientTick(config, localAnimationActive, peakStage, specialVibratorScalar, energizedLevel, energizedHeartPulse); + } catch (LinkageError e) { + markUnavailable(e); + } + } + } + + private static boolean isAvailable() { + Boolean cached = available; + if (cached != null) { + return cached; + } + + try { + ClassLoader loader = NonIntifaceBridge.class.getClassLoader(); + Class.forName("io.github.blackspherefollower.buttplug4j.ButtplugException", false, loader); + Class.forName("io.github.blackspherefollower.buttplug4j.connectors.jetty.websocket.client.ButtplugClientWSClient", false, loader); + Class.forName("com.fasterxml.jackson.core.JsonProcessingException", false, loader); + Class.forName("com.fasterxml.jackson.databind.ObjectMapper", false, loader); + available = true; + return true; + } catch (ClassNotFoundException | LinkageError e) { + markUnavailable(e); + return false; + } + } + + private static boolean isUsable() { + return isAvailable() && !NonIntifaceDependencyManager.generatedBundleOutdated() && !NonIntifaceDependencyManager.restartRequired(); + } + + private static void markUnavailable(Throwable error) { + available = false; + if (!warnedUnavailable) { + warnedUnavailable = true; + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io integration is unavailable; disabling toy output.", error); + } + } +} diff --git a/src/main/java/com/nonid/client/intiface/NonIntifaceClient.java b/src/main/java/com/nonid/client/intiface/NonIntifaceClient.java new file mode 100644 index 0000000..d6229d4 --- /dev/null +++ b/src/main/java/com/nonid/client/intiface/NonIntifaceClient.java @@ -0,0 +1,843 @@ +package com.nonid.client.intiface; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import io.github.blackspherefollower.buttplug4j.client.ButtplugClientDevice; +import io.github.blackspherefollower.buttplug4j.client.ButtplugOutput; +import io.github.blackspherefollower.buttplug4j.connectors.jetty.websocket.client.ButtplugClientWSClient; +import io.github.blackspherefollower.buttplug4j.protocol.ButtplugMessage; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.URI; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import net.minecraft.text.Text; + +public final class NonIntifaceClient { + private static final int CONNECT_TIMEOUT_MS = 8000; + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "NoN Intiface"); + thread.setDaemon(true); + return thread; + }); + private static volatile ButtplugClientWSClient client; + private static volatile NonIntifaceClient.State state = NonIntifaceClient.State.DISCONNECTED; + private static volatile String detail = ""; + private static volatile long lastPulseMs = 0L; + private static volatile long pulseToken = 0L; + private static volatile ScheduledFuture pendingStop; + private static volatile ScheduledFuture pendingStrokerReturn; + private static volatile boolean requestedAnimationActive = false; + private static volatile boolean requestedPeakStage = false; + private static volatile boolean requestedIntifaceEnabled = false; + private static volatile int requestedBaselineStrengthPercent = Integer.MIN_VALUE; + private static volatile int requestedOscillatorRegularSpeedPercent = Integer.MIN_VALUE; + private static volatile int requestedOscillatorPeakSpeedPercent = Integer.MIN_VALUE; + private static volatile double requestedSpecialVibratorScalar = Double.NaN; + private static volatile int requestedEnergizedLevel = 0; + private static volatile int requestedEnergizedBasePercent = 0; + private static volatile int requestedEnergizedPulsePercent = 0; + private static volatile int requestedEnergizedPulsePermille = 0; + private static volatile boolean baselineActive = false; + private static volatile int baselineStrengthPercent = 0; + private static volatile boolean oscillatorActive = false; + private static volatile int oscillatorSpeedPercent = 0; + private static volatile boolean specialVibratorActive = false; + private static volatile double specialVibratorScalar = 0.0; + private static volatile boolean energizedActive = false; + private static volatile int energizedBasePercent = 0; + private static volatile int energizedPulsePercent = 0; + private static volatile double energizedPulse = 0.0; + private static volatile double currentScalar = 0.0; + private static volatile double currentOscillatorScalar = 0.0; + private static volatile boolean suppressBaselineUntilInactive = false; + private static volatile long connectAttemptId = 0L; + + private NonIntifaceClient() { + } + + public static void onWorldJoin(NonConfig config) { + if (config != null && config.isIntifaceEnabled() && config.isIntifaceAutoConnectOnWorldJoin()) { + connect(config); + } else { + updateDisabledState(config); + } + } + + public static void onWorldDisconnect(NonConfig config) { + if (config != null && config.isIntifaceStopOnDisconnect()) { + stopAll(); + } + + disconnect(); + } + + public static void connect(NonConfig config) { + if (config != null && config.isIntifaceEnabled()) { + String url = config.getIntifaceServerUrl(); + NeedsOfNature.LOGGER.info("[NoN] Buttplug.io connect requested: url={}", url); + executeSafely("Connect failed", () -> connectInternal(url)); + } else { + state = NonIntifaceClient.State.DISABLED; + detail = ""; + } + } + + public static void disconnect() { + executeSafely("Disconnect failed", () -> { + cancelPendingStop(); + cancelPendingStrokerReturn(); + clearBaselineState(); + disconnectInternal(); + state = NonIntifaceClient.State.DISCONNECTED; + detail = ""; + }); + } + + public static void startScanning(NonConfig config) { + if (config != null && config.isIntifaceEnabled()) { + executeSafely("Scan failed", () -> { + connectInternal(config.getIntifaceServerUrl()); + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + try { + state = NonIntifaceClient.State.SCANNING; + detail = ""; + activeClient.startScanning(); + } catch (Exception e) { + setError("Scan failed", e); + } + } + }); + } else { + state = NonIntifaceClient.State.DISABLED; + detail = ""; + } + } + + public static void stopAll() { + executeSafely("Stop failed", () -> { + suppressBaselineUntilInactive = true; + baselineActive = false; + oscillatorActive = false; + specialVibratorActive = false; + specialVibratorScalar = 0.0; + energizedActive = false; + energizedBasePercent = 0; + energizedPulsePercent = 0; + energizedPulse = 0.0; + cancelPendingStop(); + cancelPendingStrokerReturn(); + stopAllInternal(true); + }); + } + + public static void testPulse(NonConfig config, boolean peak) { + if (peak) { + pulsePeakReactiveImpact(config); + } else { + pulseReactiveImpact(config); + } + } + + public static void pulseReactiveImpact(NonConfig config) { + pulseReactiveImpact(config, 0.0); + } + + public static void pulseReactiveImpact(NonConfig config, double nextReactiveImpactDelayMs) { + if (config != null && config.isIntifaceEnabled()) { + queueReactiveImpact(config, false, nextReactiveImpactDelayMs); + } + } + + public static void pulsePeakReactiveImpact(NonConfig config) { + pulsePeakReactiveImpact(config, 0.0); + } + + public static void pulsePeakReactiveImpact(NonConfig config, double nextReactiveImpactDelayMs) { + if (config != null && config.isIntifaceEnabled()) { + queueReactiveImpact(config, true, nextReactiveImpactDelayMs); + } + } + + public static void clientTick(NonConfig config, boolean localAnimationActive, boolean peakStage) { + clientTick(config, localAnimationActive, peakStage, Double.NaN); + } + + public static void clientTick(NonConfig config, boolean localAnimationActive, boolean peakStage, double specialVibratorScalar) { + clientTick(config, localAnimationActive, peakStage, specialVibratorScalar, 0, 0.0); + } + + public static void clientTick( + NonConfig config, boolean localAnimationActive, boolean peakStage, double specialVibratorScalar, int energizedLevel, double energizedHeartPulse + ) { + boolean enabled = config != null && config.isIntifaceEnabled(); + int strengthPercent = enabled ? config.getIntifaceAnimationBaselineStrengthPercent() : 0; + int oscillatorRegularSpeedPercent = enabled ? config.getIntifaceOscillatorRegularSpeedPercent() : 0; + int oscillatorPeakSpeedPercent = enabled ? config.getIntifaceOscillatorPeakSpeedPercent() : 0; + double sanitizedSpecialScalar = sanitizeSpecialVibratorScalar(specialVibratorScalar); + int sanitizedEnergizedLevel = enabled ? Math.max(0, Math.min(3, energizedLevel)) : 0; + int energizedBase = sanitizedEnergizedLevel > 0 ? config.getIntifaceEnergizedBasePercent(sanitizedEnergizedLevel) : 0; + int energizedPeak = sanitizedEnergizedLevel > 0 ? config.getIntifaceEnergizedPulsePercent(sanitizedEnergizedLevel) : 0; + double sanitizedEnergizedPulse = sanitizeUnit(energizedHeartPulse); + int pulsePermille = (int)Math.round(sanitizedEnergizedPulse * 1000.0); + if (!localAnimationActive && suppressBaselineUntilInactive) { + suppressBaselineUntilInactive = false; + } + + if (localAnimationActive != requestedAnimationActive + || peakStage != requestedPeakStage + || strengthPercent != requestedBaselineStrengthPercent + || oscillatorRegularSpeedPercent != requestedOscillatorRegularSpeedPercent + || oscillatorPeakSpeedPercent != requestedOscillatorPeakSpeedPercent + || !specialScalarEquals(sanitizedSpecialScalar, requestedSpecialVibratorScalar) + || sanitizedEnergizedLevel != requestedEnergizedLevel + || energizedBase != requestedEnergizedBasePercent + || energizedPeak != requestedEnergizedPulsePercent + || pulsePermille != requestedEnergizedPulsePermille + || enabled != requestedIntifaceEnabled) { + requestedAnimationActive = localAnimationActive; + requestedPeakStage = peakStage; + requestedIntifaceEnabled = enabled; + requestedBaselineStrengthPercent = strengthPercent; + requestedOscillatorRegularSpeedPercent = oscillatorRegularSpeedPercent; + requestedOscillatorPeakSpeedPercent = oscillatorPeakSpeedPercent; + requestedSpecialVibratorScalar = sanitizedSpecialScalar; + requestedEnergizedLevel = sanitizedEnergizedLevel; + requestedEnergizedBasePercent = energizedBase; + requestedEnergizedPulsePercent = energizedPeak; + requestedEnergizedPulsePermille = pulsePermille; + executeSafely( + "Update failed", + () -> updateAnimationOutputs( + enabled, + localAnimationActive, + peakStage, + strengthPercent, + oscillatorRegularSpeedPercent, + oscillatorPeakSpeedPercent, + sanitizedSpecialScalar, + sanitizedEnergizedLevel, + energizedBase, + energizedPeak, + sanitizedEnergizedPulse + ) + ); + } + } + + public static Text statusText(NonConfig config) { + updateDisabledState(config); + int devices = deviceCount(); + String suffix = detail != null && !detail.isBlank() ? " - " + detail : ""; + return Text.translatable("config.needsofnature.intiface.status." + state.name().toLowerCase(Locale.ROOT), new Object[]{devices}) + .append(Text.literal(suffix)); + } + + public static int deviceCount() { + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + try { + return activeClient.getDevices().size(); + } catch (RuntimeException e) { + return 0; + } + } else { + return 0; + } + } + + public static boolean isConnected() { + ButtplugClientWSClient activeClient = client; + return activeClient != null && activeClient.isConnected(); + } + + private static void queueReactiveImpact(NonConfig config, boolean peak, double nextReactiveImpactDelayMs) { + int strengthPercent = peak ? config.getIntifacePeakReactiveImpactStrengthPercent() : config.getIntifaceReactiveImpactStrengthPercent(); + int durationMs = peak ? config.getIntifacePeakReactiveImpactDurationMs() : config.getIntifaceReactiveImpactDurationMs(); + int cooldownMs = config.getIntifaceCooldownMs(); + double scalar = Math.max(sustainedVibratorScalar(), Math.max(0.0, Math.min(1.0, strengthPercent / 100.0))); + int strokerMin = config.getIntifaceStrokerMinDistancePercent(); + int strokerMax = config.getIntifaceStrokerMaxDistancePercent(); + int strokerMoveDurationMs = peak ? config.getIntifaceStrokerPeakMoveDurationMs() : config.getIntifaceStrokerRegularMoveDurationMs(); + executeSafely("Pulse failed", () -> { + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + long now = System.currentTimeMillis(); + if (cooldownMs <= 0 || now - lastPulseMs >= cooldownMs) { + boolean sentAny = false; + if (!specialVibratorActive && scalar > 0.0 && durationMs > 0) { + sentAny = sendVibrate(activeClient, scalar) || sentAny; + long token = ++pulseToken; + cancelPendingStop(); + pendingStop = EXECUTOR.schedule(() -> { + if (token == pulseToken) { + returnToBaselineOrStop(activeClient); + pendingStop = null; + } + }, durationMs, TimeUnit.MILLISECONDS); + } + + sentAny = triggerStrokerImpact(activeClient, strokerMin, strokerMax, strokerMoveDurationMs, nextReactiveImpactDelayMs) || sentAny; + if (sentAny) { + lastPulseMs = now; + } + } + } + }); + } + + private static void updateAnimationOutputs( + boolean enabled, + boolean localAnimationActive, + boolean peakStage, + int strengthPercent, + int oscillatorRegularSpeedPercent, + int oscillatorPeakSpeedPercent, + double specialScalar, + int energizedLevel, + int energizedBaseStrengthPercent, + int energizedPulseStrengthPercent, + double energizedHeartPulse + ) { + if (!localAnimationActive) { + suppressBaselineUntilInactive = false; + } + + boolean shouldUseSpecialVibrator = enabled && localAnimationActive && !Double.isNaN(specialScalar) && !suppressBaselineUntilInactive; + int clampedStrength = Math.max(0, Math.min(100, strengthPercent)); + boolean shouldUseBaseline = enabled && localAnimationActive && clampedStrength > 0 && !suppressBaselineUntilInactive && !shouldUseSpecialVibrator; + baselineActive = shouldUseBaseline; + baselineStrengthPercent = shouldUseBaseline ? clampedStrength : 0; + specialVibratorActive = shouldUseSpecialVibrator; + specialVibratorScalar = shouldUseSpecialVibrator ? Math.max(0.0, Math.min(1.0, specialScalar)) : 0.0; + int clampedEnergizedBase = Math.max(0, Math.min(100, energizedBaseStrengthPercent)); + int clampedEnergizedPeak = Math.max(0, Math.min(100, energizedPulseStrengthPercent)); + boolean shouldUseEnergized = enabled && energizedLevel > 0 && Math.max(clampedEnergizedBase, clampedEnergizedPeak) > 0 && !shouldUseSpecialVibrator; + energizedActive = shouldUseEnergized; + energizedBasePercent = shouldUseEnergized ? clampedEnergizedBase : 0; + energizedPulsePercent = shouldUseEnergized ? clampedEnergizedPeak : 0; + energizedPulse = shouldUseEnergized ? sanitizeUnit(energizedHeartPulse) : 0.0; + int requestedOscillatorSpeed = Math.max(0, Math.min(100, peakStage ? oscillatorPeakSpeedPercent : oscillatorRegularSpeedPercent)); + boolean shouldUseOscillator = enabled && localAnimationActive && requestedOscillatorSpeed > 0 && !suppressBaselineUntilInactive; + oscillatorActive = shouldUseOscillator; + oscillatorSpeedPercent = shouldUseOscillator ? requestedOscillatorSpeed : 0; + if (shouldUseSpecialVibrator) { + cancelPendingStop(); + } + + if (!shouldUseBaseline && !shouldUseOscillator && !shouldUseSpecialVibrator && !shouldUseEnergized) { + cancelPendingStop(); + cancelPendingStrokerReturn(); + if (currentScalar > 0.0) { + sendVibrate(client, 0.0); + } + + if (currentOscillatorScalar > 0.0) { + sendOscillate(client, 0.0); + } + } else { + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + if (pendingStop == null || shouldUseSpecialVibrator) { + double vibrateScalar = shouldUseSpecialVibrator ? specialVibratorScalar : sustainedVibratorScalar(); + if (vibrateScalar > 0.0 && Math.abs(currentScalar - vibrateScalar) > 0.001) { + sendVibrate(activeClient, vibrateScalar); + } else if (vibrateScalar <= 0.0 && currentScalar > 0.0) { + sendVibrate(activeClient, 0.0); + } + + double oscillateScalar = oscillatorScalar(); + if (oscillateScalar > 0.0 && Math.abs(currentOscillatorScalar - oscillateScalar) > 0.001) { + sendOscillate(activeClient, oscillateScalar); + } else if (oscillateScalar <= 0.0 && currentOscillatorScalar > 0.0) { + sendOscillate(activeClient, 0.0); + } + } + } + } + } + + private static double baselineScalar() { + return baselineActive && baselineStrengthPercent > 0 ? Math.max(0.0, Math.min(1.0, baselineStrengthPercent / 100.0)) : 0.0; + } + + private static double energizedScalar() { + if (!energizedActive) { + return 0.0; + } + + double base = Math.max(0.0, Math.min(1.0, energizedBasePercent / 100.0)); + double peak = Math.max(0.0, Math.min(1.0, energizedPulsePercent / 100.0)); + return base + (peak - base) * sanitizeUnit(energizedPulse); + } + + private static double sustainedVibratorScalar() { + return Math.max(baselineScalar(), energizedScalar()); + } + + private static double oscillatorScalar() { + return oscillatorActive && oscillatorSpeedPercent > 0 ? Math.max(0.0, Math.min(1.0, oscillatorSpeedPercent / 100.0)) : 0.0; + } + + private static double sanitizeSpecialVibratorScalar(double scalar) { + if (Double.isNaN(scalar)) { + return Double.NaN; + } else { + return Double.isInfinite(scalar) ? 0.0 : Math.max(0.0, Math.min(1.0, scalar)); + } + } + + private static double sanitizeUnit(double value) { + return !Double.isNaN(value) && !Double.isInfinite(value) ? Math.max(0.0, Math.min(1.0, value)) : 0.0; + } + + private static boolean specialScalarEquals(double first, double second) { + return !Double.isNaN(first) && !Double.isNaN(second) ? Math.abs(first - second) < 0.001 : Double.isNaN(first) && Double.isNaN(second); + } + + private static void returnToBaselineOrStop(ButtplugClientWSClient activeClient) { + double scalar = sustainedVibratorScalar(); + if (!(scalar > 0.0) || activeClient == null || !activeClient.isConnected() || !sendVibrate(activeClient, scalar)) { + if (activeClient != null && activeClient.isConnected()) { + sendVibrate(activeClient, 0.0); + } else { + currentScalar = 0.0; + } + } + } + + private static void connectInternal(String url) { + long attemptId = ++connectAttemptId; + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + state = NonIntifaceClient.State.CONNECTED; + detail = ""; + NeedsOfNature.LOGGER.info("[NoN] Buttplug.io connect skipped: already connected. attempt={}", attemptId); + } else { + disconnectInternal(); + ButtplugClientWSClient nextClient = null; + + try { + state = NonIntifaceClient.State.CONNECTING; + detail = "Connecting to " + url; + NeedsOfNature.LOGGER.info("[NoN] Buttplug.io connect started: attempt={} url={} timeout={}ms", new Object[]{attemptId, url, 8000}); + nextClient = new ButtplugClientWSClient("NeedsOfNature"); + nextClient.setDeviceAddedHandler( + device -> { + if (attemptId == connectAttemptId) { + state = NonIntifaceClient.State.CONNECTED; + detail = Text.translatable("config.needsofnature.intiface.status.device_added", new Object[]{device.getDisplayName()}).getString(); + updateAnimationOutputs( + requestedIntifaceEnabled, + requestedAnimationActive, + requestedPeakStage, + requestedBaselineStrengthPercent, + requestedOscillatorRegularSpeedPercent, + requestedOscillatorPeakSpeedPercent, + requestedSpecialVibratorScalar, + requestedEnergizedLevel, + requestedEnergizedBasePercent, + requestedEnergizedPulsePercent, + requestedEnergizedPulsePermille / 1000.0 + ); + } + } + ); + nextClient.setDeviceRemovedHandler(index -> { + if (attemptId == connectAttemptId) { + state = NonIntifaceClient.State.CONNECTED; + detail = Text.translatable("config.needsofnature.intiface.status.device_removed").getString(); + } + }); + nextClient.setScanningFinishedHandler(() -> { + if (attemptId == connectAttemptId) { + state = NonIntifaceClient.State.CONNECTED; + detail = Text.translatable("config.needsofnature.intiface.status.scan_finished").getString(); + } + }); + nextClient.setErrorHandler(error -> { + if (attemptId == connectAttemptId) { + setError(error.getErrorMessage(), error.getException()); + } + }); + ButtplugClientWSClient connectClient = nextClient; + FutureTask connectTask = new FutureTask<>(() -> { + connectClient.connect(new URI(url)); + return null; + }); + Thread connectThread = new Thread(connectTask, "NoN Intiface Connect"); + connectThread.setDaemon(true); + connectThread.start(); + connectTask.get(8000L, TimeUnit.MILLISECONDS); + if (attemptId != connectAttemptId) { + disconnectClient(nextClient); + return; + } + + client = nextClient; + state = NonIntifaceClient.State.CONNECTED; + detail = ""; + NeedsOfNature.LOGGER.info("[NoN] Buttplug.io connect succeeded: attempt={} url={}", attemptId, url); + updateAnimationOutputs( + requestedIntifaceEnabled, + requestedAnimationActive, + requestedPeakStage, + requestedBaselineStrengthPercent, + requestedOscillatorRegularSpeedPercent, + requestedOscillatorPeakSpeedPercent, + requestedSpecialVibratorScalar, + requestedEnergizedLevel, + requestedEnergizedBasePercent, + requestedEnergizedPulsePercent, + requestedEnergizedPulsePermille / 1000.0 + ); + } catch (TimeoutException e) { + connectAttemptId++; + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io connect timed out: attempt={} url={} timeout={}ms", new Object[]{attemptId, url, 8000}); + setError("Connect timed out after 8s", e); + disconnectClient(nextClient); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + connectAttemptId++; + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io connect interrupted: attempt={} url={}", new Object[]{attemptId, url, e}); + setError("Connect interrupted", e); + disconnectClient(nextClient); + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + connectAttemptId++; + handleConnectFailure(attemptId, url, cause); + disconnectClient(nextClient); + } catch (Exception e) { + connectAttemptId++; + handleConnectFailure(attemptId, url, e); + disconnectClient(nextClient); + } catch (Throwable e) { + connectAttemptId++; + handleConnectFailure(attemptId, url, e); + disconnectClient(nextClient); + } + } + } + + private static void handleConnectFailure(long attemptId, String url, Throwable throwable) { + String expectedMessage = expectedConnectFailureMessage(throwable); + if (expectedMessage != null) { + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io connect failed: attempt={} url={} reason={}", new Object[]{attemptId, url, expectedMessage}); + setError(expectedMessage, null); + } else { + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io connect failed: attempt={} url={}", new Object[]{attemptId, url, throwable}); + setError("Connect failed: " + simpleThrowableName(throwable), throwable); + } + } + + private static String expectedConnectFailureMessage(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof ConnectException) { + return "Could not reach Buttplug.io?"; + } + + if (current instanceof NoRouteToHostException || current instanceof UnknownHostException || current instanceof UnresolvedAddressException) { + return "Could not reach Buttplug.io server"; + } + + String className = current.getClass().getSimpleName().toLowerCase(Locale.ROOT); + if (className.contains("upgradeexception")) { + return "Could not connect to Buttplug.io. Is Intiface Central running and using the configured WebSocket URL?"; + } + + String message = current.getMessage(); + if (message != null) { + String lower = message.toLowerCase(Locale.ROOT); + if (lower.contains("connection refused") + || lower.contains("failed to connect") + || lower.contains("connection reset") + || lower.contains("connection timed out") + || lower.contains("connection timeout") + || lower.contains("connect timed out")) { + return "Could not reach Buttplug.io. Is Intiface Central running?"; + } + + if (lower.contains("failed to upgrade to websocket") || lower.contains("unexpected http response status code") || lower.contains("400 bad request") + ) + { + return "Could not connect to Buttplug.io. Is Intiface Central running and using the configured WebSocket URL?"; + } + } + } + + return null; + } + + private static String simpleThrowableName(Throwable throwable) { + return throwable == null ? "Unknown error" : throwable.getClass().getSimpleName(); + } + + private static boolean triggerStrokerImpact( + ButtplugClientWSClient activeClient, int minDistancePercent, int maxDistancePercent, int moveDurationMs, double nextReactiveImpactDelayMs + ) { + if (activeClient != null && activeClient.isConnected()) { + int min = Math.max(0, Math.min(100, Math.min(minDistancePercent, maxDistancePercent))); + int max = Math.max(0, Math.min(100, Math.max(minDistancePercent, maxDistancePercent))); + int durationMs = Math.max(0, Math.min(10000, moveDurationMs)); + if (durationMs > 0 && max > min) { + double minPosition = min / 100.0; + double maxPosition = max / 100.0; + boolean sentAny = sendLinear(activeClient, maxPosition, durationMs); + if (!sentAny) { + return false; + } + + cancelPendingStrokerReturn(); + long arrivalDelayMs = nextReactiveImpactDelayMs > 0.0 ? Math.max(0L, Math.round(nextReactiveImpactDelayMs / 2.0)) : 500L; + long scheduleDelayMs = Math.max(durationMs, arrivalDelayMs - durationMs); + pendingStrokerReturn = EXECUTOR.schedule(() -> { + sendLinear(activeClient, minPosition, durationMs); + pendingStrokerReturn = null; + }, scheduleDelayMs, TimeUnit.MILLISECONDS); + return true; + } else { + return false; + } + } else { + return false; + } + } + + private static boolean sendVibrate(ButtplugClientWSClient activeClient, double scalar) { + List devices = activeClient.getDevices(); + if (devices.isEmpty()) { + detail = Text.translatable("config.needsofnature.intiface.status.no_devices").getString(); + return false; + } + + boolean sentAny = false; + + for (ButtplugClientDevice device : devices) { + try { + if (device.hasOutput(ButtplugOutput.VIBRATE)) { + waitForDeviceCommands(device.runVibrateFloat((float)scalar)); + sentAny = true; + } + } catch (Exception e) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface pulse failed for device {}.", device.getDisplayName(), e); + } + } + + if (sentAny) { + currentScalar = scalar; + } + + if (!sentAny) { + detail = Text.translatable("config.needsofnature.intiface.status.no_vibrate_devices").getString(); + } + + return sentAny; + } + + private static boolean sendOscillate(ButtplugClientWSClient activeClient, double scalar) { + if (activeClient != null && activeClient.isConnected()) { + List devices = activeClient.getDevices(); + boolean sentAny = false; + + for (ButtplugClientDevice device : devices) { + try { + if (device.hasOutput(ButtplugOutput.OSCILLATE)) { + waitForDeviceCommands(device.runOscillateFloat((float)scalar)); + sentAny = true; + } + } catch (Exception e) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface oscillate failed for device {}.", device.getDisplayName(), e); + } + } + + if (sentAny) { + currentOscillatorScalar = scalar; + } + + return sentAny; + } else { + currentOscillatorScalar = 0.0; + return false; + } + } + + private static boolean sendLinear(ButtplugClientWSClient activeClient, double position, long durationMs) { + if (activeClient != null && activeClient.isConnected()) { + List devices = activeClient.getDevices(); + boolean sentAny = false; + + for (ButtplugClientDevice device : devices) { + try { + if (device.hasOutput(ButtplugOutput.HW_POSITION_WITH_DURATION)) { + waitForDeviceCommands(device.runHwPositionWithDurationFloat((float)position, (int)Math.max(0L, durationMs))); + } else { + if (!device.hasOutput(ButtplugOutput.POSITION)) { + continue; + } + + waitForDeviceCommands(device.runPositionFloat((float)position)); + } + + sentAny = true; + } catch (Exception e) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface linear failed for device {}.", device.getDisplayName(), e); + } + } + + return sentAny; + } else { + return false; + } + } + + private static void waitForDeviceCommands(List> futures) throws Exception { + if (futures != null && !futures.isEmpty()) { + for (Future future : futures) { + if (future != null) { + future.get(750L, TimeUnit.MILLISECONDS); + } + } + } + } + + private static void stopAllInternal(boolean userVisible) { + ButtplugClientWSClient activeClient = client; + if (activeClient != null && activeClient.isConnected()) { + try { + activeClient.stopAllDevices(); + currentScalar = 0.0; + currentOscillatorScalar = 0.0; + if (userVisible) { + detail = Text.translatable("config.needsofnature.intiface.status.stopped").getString(); + } + } catch (Exception e) { + setError("Stop failed", e); + } + } else { + currentScalar = 0.0; + currentOscillatorScalar = 0.0; + } + } + + private static void disconnectInternal() { + ButtplugClientWSClient activeClient = client; + client = null; + currentScalar = 0.0; + currentOscillatorScalar = 0.0; + if (activeClient != null) { + try { + activeClient.disconnect(); + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface disconnect failed.", e); + } + } + } + + private static void disconnectClient(ButtplugClientWSClient targetClient) { + if (targetClient != null) { + if (client == targetClient) { + client = null; + } + + currentScalar = 0.0; + currentOscillatorScalar = 0.0; + + try { + targetClient.disconnect(); + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface disconnect failed.", e); + } + } + } + + private static void cancelPendingStop() { + ScheduledFuture stop = pendingStop; + pendingStop = null; + if (stop != null) { + stop.cancel(false); + } + } + + private static void cancelPendingStrokerReturn() { + ScheduledFuture stop = pendingStrokerReturn; + pendingStrokerReturn = null; + if (stop != null) { + stop.cancel(false); + } + } + + private static void setError(String message, Throwable throwable) { + state = NonIntifaceClient.State.ERROR; + detail = message != null && !message.isBlank() ? message : "Unknown error"; + if (throwable != null) { + NeedsOfNature.LOGGER.debug("[NoN] Intiface error: {}", detail, throwable); + } else { + NeedsOfNature.LOGGER.debug("[NoN] Intiface error: {}", detail); + } + } + + private static void executeSafely(String errorMessage, Runnable action) { + EXECUTOR.execute(() -> { + try { + action.run(); + } catch (Throwable throwable) { + NeedsOfNature.LOGGER.warn("[NoN] Buttplug.io worker failure: {}", errorMessage, throwable); + setError(errorMessage + ": " + throwable.getClass().getSimpleName(), throwable); + } + }); + } + + private static void updateDisabledState(NonConfig config) { + if (config != null && !config.isIntifaceEnabled()) { + state = NonIntifaceClient.State.DISABLED; + detail = ""; + } + } + + private static void clearBaselineState() { + requestedAnimationActive = false; + requestedPeakStage = false; + requestedIntifaceEnabled = false; + requestedBaselineStrengthPercent = Integer.MIN_VALUE; + requestedOscillatorRegularSpeedPercent = Integer.MIN_VALUE; + requestedOscillatorPeakSpeedPercent = Integer.MIN_VALUE; + requestedSpecialVibratorScalar = Double.NaN; + requestedEnergizedLevel = 0; + requestedEnergizedBasePercent = 0; + requestedEnergizedPulsePercent = 0; + requestedEnergizedPulsePermille = 0; + baselineActive = false; + baselineStrengthPercent = 0; + oscillatorActive = false; + oscillatorSpeedPercent = 0; + specialVibratorActive = false; + specialVibratorScalar = 0.0; + energizedActive = false; + energizedBasePercent = 0; + energizedPulsePercent = 0; + energizedPulse = 0.0; + currentScalar = 0.0; + currentOscillatorScalar = 0.0; + suppressBaselineUntilInactive = false; + } + + private enum State { + DISABLED, + DISCONNECTED, + CONNECTING, + CONNECTED, + SCANNING, + ERROR; + } +} diff --git a/src/main/java/com/nonid/client/intiface/NonIntifaceDependencyManager.java b/src/main/java/com/nonid/client/intiface/NonIntifaceDependencyManager.java new file mode 100644 index 0000000..417e57c --- /dev/null +++ b/src/main/java/com/nonid/client/intiface/NonIntifaceDependencyManager.java @@ -0,0 +1,458 @@ +package com.nonid.client.intiface; + +import com.nonid.NeedsOfNature; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpClient.Redirect; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.stream.Stream; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +public final class NonIntifaceDependencyManager { + private static final String GENERATED_MOD_ID = "needsofnature_intiface_libraries"; + private static final String GENERATED_JAR_NAME = "needsofnature-intiface-libs-generated.jar"; + private static final String BUNDLE_MARKER = "META-INF/needsofnature-intiface-libs.sha256"; + private static final String MAVEN_CENTRAL = "https://repo.maven.apache.org/maven2/"; + private static final String CORE_CLASS_ENTRY = "io/github/blackspherefollower/buttplug4j/ButtplugException.class"; + private static final String CONNECTOR_CLASS_ENTRY = "io/github/blackspherefollower/buttplug4j/connectors/jetty/websocket/client/ButtplugClientWSClient.class"; + private static final String JACKSON_CLASS_ENTRY = "com/fasterxml/jackson/databind/ObjectMapper.class"; + private static final List ARTIFACTS = List.of( + new NonIntifaceDependencyManager.Artifact( + "io.github.blackspherefollower", + "buttplug4j.connectors.jetty.websocket.client", + "4.0.278", + "6ffb6507595137ae6cd3cda932d47e3b6ef32e12393c58ef60d7ec231ff2752c", + 6320L + ), + new NonIntifaceDependencyManager.Artifact( + "io.github.blackspherefollower", "buttplug4j", "4.0.278", "094055e1bf64bc416b7a984b80d5216febbc20dcf5b5149ca1e4513056ccc4fa", 82360L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty.websocket", "websocket-client", "9.4.58.v20250814", "fdb8648fb1139ab001c8d5ebc4f3ab5baf007f48bd86ec2969c504ac3011f878", 46116L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty.websocket", "websocket-common", "9.4.58.v20250814", "07c62b7ceaff2d7160aff98b912f28da9f2c66ed7adec972e39af5176afec1ae", 217092L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty.websocket", "websocket-api", "9.4.58.v20250814", "213d146065ebf2cbfa8a9c06b4cdfe79f94c0af3f93c15d9e67c0e3af015067f", 52516L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty", "jetty-client", "9.4.58.v20250814", "629371e5f65f321832930538bd485e5fd637d180412ea75f592881921e2d4b83", 331779L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty", "jetty-http", "9.4.58.v20250814", "8f49b8583fc8dbbfe3a0ba80a04c97df64ebabca2981b56ee403c989aba9d4da", 253188L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty", "jetty-io", "9.4.58.v20250814", "f55b2cc7c05244fd7b1773e99979b740002b0186e45b8a688243d89c7006fe21", 184923L + ), + new NonIntifaceDependencyManager.Artifact( + "org.eclipse.jetty", "jetty-util", "9.4.58.v20250814", "8d591679d318d20c8fb2b1e83900562b3c4571ff7a189696be700293e0204146", 595392L + ), + new NonIntifaceDependencyManager.Artifact( + "com.fasterxml.jackson.core", "jackson-annotations", "2.20", "959a2ffb2d591436f51f183c6a521fc89347912f711bf0cae008cdf045d95319", 79195L + ), + new NonIntifaceDependencyManager.Artifact( + "com.fasterxml.jackson.core", "jackson-core", "2.20.1", "ffab4d957daa2796cf24cb66d0b78a7090f1bcbe17c3a4578f09affaaf137089", 593204L + ), + new NonIntifaceDependencyManager.Artifact( + "com.fasterxml.jackson.core", "jackson-databind", "2.20.1", "34bbeb4526fff4f8565b12106bf85a6afcbae858966d489b54214ac46b2e26e8", 1688464L + ) + ); + private static final ExecutorService INSTALL_EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "NoN Intiface Library Installer"); + thread.setDaemon(true); + return thread; + }); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15L)).followRedirects(Redirect.NORMAL).build(); + private static volatile NonIntifaceDependencyManager.InstallState installState = NonIntifaceDependencyManager.InstallState.IDLE; + private static volatile String detail = ""; + private static volatile long cachedBundleModified = Long.MIN_VALUE; + private static volatile boolean cachedBundleValid = false; + + private NonIntifaceDependencyManager() { + } + + public static boolean runtimeLibrariesAvailable() { + return NonIntifaceBridge.isRuntimeAvailable(); + } + + public static boolean librariesUsable() { + return runtimeLibrariesAvailable() && !generatedBundleOutdated() && installState != NonIntifaceDependencyManager.InstallState.INSTALLED_RESTART_REQUIRED; + } + + public static boolean installRequired() { + return !runtimeLibrariesAvailable() || generatedBundleOutdated(); + } + + public static boolean generatedBundleOutdated() { + return generatedBundleExists() && !generatedBundleValid(); + } + + private static boolean generatedBundleExists() { + return Files.exists(generatedBundlePath()); + } + + public static boolean generatedBundleValid() { + Path bundle = generatedBundlePath(); + long modified = -1L; + + try { + if (Files.exists(bundle)) { + modified = Files.getLastModifiedTime(bundle).toMillis(); + } + } catch (IOException ignored) { + modified = -1L; + } + + if (modified == cachedBundleModified) { + return cachedBundleValid; + } + + boolean valid = verifyGeneratedBundle(bundle); + cachedBundleModified = modified; + cachedBundleValid = valid; + return valid; + } + + public static boolean restartRequired() { + return installState == NonIntifaceDependencyManager.InstallState.INSTALLED_RESTART_REQUIRED || !runtimeLibrariesAvailable() && generatedBundleValid(); + } + + public static boolean installing() { + return installState == NonIntifaceDependencyManager.InstallState.DOWNLOADING || installState == NonIntifaceDependencyManager.InstallState.BUILDING; + } + + public static Text statusText() { + if (installing()) { + return Text.translatable("config.needsofnature.intiface.libs.status.installing", new Object[]{detail}); + } else if (restartRequired()) { + return Text.translatable("config.needsofnature.intiface.libs.status.restart_required"); + } else if (installState == NonIntifaceDependencyManager.InstallState.FAILED) { + return Text.translatable("config.needsofnature.intiface.libs.status.failed", new Object[]{detail}); + } else if (generatedBundleOutdated()) { + return Text.translatable("config.needsofnature.intiface.libs.status.outdated"); + } else { + return runtimeLibrariesAvailable() + ? Text.translatable("config.needsofnature.intiface.libs.status.available") + : Text.translatable("config.needsofnature.intiface.libs.status.missing", new Object[]{totalSizeMbText()}); + } + } + + public static String totalSizeMbText() { + double mb = totalBytes() / 1024.0 / 1024.0; + return String.format(Locale.ROOT, "%.1f MB", mb); + } + + public static void installAsync(Runnable callback) { + if (!installing()) { + installState = NonIntifaceDependencyManager.InstallState.DOWNLOADING; + detail = ""; + INSTALL_EXECUTOR.execute(() -> { + try { + List downloads = downloadArtifacts(); + installState = NonIntifaceDependencyManager.InstallState.BUILDING; + detail = "Building library bundle"; + buildGeneratedBundle(downloads); + cachedBundleModified = Long.MIN_VALUE; + cachedBundleValid = false; + installState = NonIntifaceDependencyManager.InstallState.INSTALLED_RESTART_REQUIRED; + detail = ""; + NeedsOfNature.LOGGER.info("[NoN] Installed Buttplug.io libraries to {}", generatedBundlePath()); + } catch (Exception e) { + installState = NonIntifaceDependencyManager.InstallState.FAILED; + detail = e.getMessage() != null && !e.getMessage().isBlank() ? e.getMessage() : e.getClass().getSimpleName(); + NeedsOfNature.LOGGER.warn("[NoN] Failed to install Buttplug.io libraries.", e); + } finally { + if (callback != null) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + client.execute(callback); + } + } + } + }); + } + } + + private static List downloadArtifacts() throws Exception { + List result = new ArrayList<>(ARTIFACTS.size()); + + for (NonIntifaceDependencyManager.Artifact artifact : ARTIFACTS) { + detail = artifact.fileName(); + URI uri = artifact.mavenUri(); + HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(60L)).GET().build(); + HttpResponse response = HTTP_CLIENT.send(request, BodyHandlers.ofByteArray()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Download failed for " + artifact.fileName() + " (" + response.statusCode() + ")"); + } + + byte[] bytes = response.body(); + String actualHash = sha256(bytes); + if (!artifact.sha256().equalsIgnoreCase(actualHash)) { + throw new IOException("Checksum mismatch for " + artifact.fileName()); + } + + result.add(new NonIntifaceDependencyManager.DownloadedArtifact(artifact, bytes)); + } + + return result; + } + + private static void buildGeneratedBundle(List downloads) throws IOException { + Path modsDir = FabricLoader.getInstance().getGameDir().resolve("mods"); + Files.createDirectories(modsDir); + Path bundle = generatedBundlePath(); + Path tempBundle = modsDir.resolve("needsofnature-intiface-libs-generated.jar.tmp"); + deleteIfExists(tempBundle); + + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(tempBundle))) { + putEntry(out, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nMulti-Release: true\n".getBytes(StandardCharsets.UTF_8)); + putEntry(out, "fabric.mod.json", generatedFabricModJson().getBytes(StandardCharsets.UTF_8)); + mergeDownloadedJars(out, downloads); + putEntry(out, "META-INF/needsofnature-intiface-libs.sha256", expectedBundleMarker().getBytes(StandardCharsets.UTF_8)); + } + + Files.move(tempBundle, bundle, StandardCopyOption.REPLACE_EXISTING); + } + + private static String generatedFabricModJson() { + return "{\n \"schemaVersion\": 1,\n \"id\": \"%s\",\n \"version\": \"1.0.0\",\n \"name\": \"NeedsOfNature Buttplug.io Libraries\",\n \"description\": \"Optional client-side libraries for NeedsOfNature Buttplug.io integration.\",\n \"environment\": \"client\"\n}\n" + .formatted("needsofnature_intiface_libraries"); + } + + private static void mergeDownloadedJars(JarOutputStream out, List downloads) throws IOException { + Set written = new HashSet<>(); + written.add("META-INF/MANIFEST.MF"); + written.add("fabric.mod.json"); + Map> serviceEntries = new LinkedHashMap<>(); + + for (NonIntifaceDependencyManager.DownloadedArtifact download : downloads) { + JarEntry entry; + try (JarInputStream in = new JarInputStream(new ByteArrayInputStream(download.bytes()))) { + while ((entry = in.getNextJarEntry()) != null) { + String name = entry.getName(); + if (!shouldSkipShadedEntry(entry, name)) { + byte[] bytes = in.readAllBytes(); + if (name.startsWith("META-INF/services/")) { + serviceEntries.computeIfAbsent(name, ignored -> new LinkedHashSet<>()).add(new String(bytes, StandardCharsets.UTF_8).strip()); + } else if (written.add(name)) { + putEntry(out, name, bytes); + } + } + } + } + } + + for (Entry> service : serviceEntries.entrySet()) { + if (written.add(service.getKey())) { + String text = String.join("\n", service.getValue()).strip(); + if (!text.isBlank()) { + text = text + "\n"; + } + + putEntry(out, service.getKey(), text.getBytes(StandardCharsets.UTF_8)); + } + } + } + + private static boolean shouldSkipShadedEntry(JarEntry entry, String name) { + if (entry.isDirectory()) { + return true; + } + + if (name.equals("fabric.mod.json")) { + return true; + } + + if (name.equals("module-info.class")) { + return true; + } + + if (name.equalsIgnoreCase("META-INF/MANIFEST.MF")) { + return true; + } + + if (name.equalsIgnoreCase("META-INF/INDEX.LIST")) { + return true; + } + + if (name.startsWith("META-INF/maven/")) { + return true; + } + + if (!name.startsWith("META-INF/")) { + return false; + } + + String upper = name.toUpperCase(Locale.ROOT); + return upper.endsWith(".SF") || upper.endsWith(".RSA") || upper.endsWith(".DSA") || upper.endsWith(".EC"); + } + + private static boolean verifyGeneratedBundle(Path bundle) { + if (!Files.exists(bundle)) { + return false; + } + + try (JarFile jar = new JarFile(bundle.toFile())) { + if (jar.getEntry("fabric.mod.json") == null) { + return false; + } + + JarEntry marker = jar.getJarEntry("META-INF/needsofnature-intiface-libs.sha256"); + if (marker == null) { + return false; + } + + try (InputStream in = jar.getInputStream(marker)) { + String actualMarker = new String(in.readAllBytes(), StandardCharsets.UTF_8); + if (!expectedBundleMarker().equals(actualMarker)) { + return false; + } + } + + return jar.getEntry("io/github/blackspherefollower/buttplug4j/ButtplugException.class") != null + && jar.getEntry("io/github/blackspherefollower/buttplug4j/connectors/jetty/websocket/client/ButtplugClientWSClient.class") != null + && jar.getEntry("com/fasterxml/jackson/databind/ObjectMapper.class") != null; + } catch (IOException e) { + return false; + } + } + + private static String expectedBundleMarker() { + StringBuilder marker = new StringBuilder("format=shaded-v1\n"); + + for (NonIntifaceDependencyManager.Artifact artifact : ARTIFACTS) { + marker.append(artifact.group()) + .append(':') + .append(artifact.artifact()) + .append(':') + .append(artifact.version()) + .append(' ') + .append(artifact.sha256()) + .append('\n'); + } + + return marker.toString(); + } + + private static void putEntry(JarOutputStream out, String name, byte[] bytes) throws IOException { + JarEntry entry = new JarEntry(name); + entry.setTime(0L); + out.putNextEntry(entry); + + try (InputStream in = new ByteArrayInputStream(bytes)) { + in.transferTo(out); + } + + out.closeEntry(); + } + + private static Path generatedBundlePath() { + return FabricLoader.getInstance().getGameDir().resolve("mods").resolve("needsofnature-intiface-libs-generated.jar"); + } + + private static long totalBytes() { + long total = 0L; + + for (NonIntifaceDependencyManager.Artifact artifact : ARTIFACTS) { + total += artifact.bytes(); + } + + return total; + } + + private static String sha256(byte[] bytes) throws IOException { + return sha256(new ByteArrayInputStream(bytes)); + } + + private static String sha256(InputStream input) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + + int read; + while ((read = input.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 unavailable", e); + } + } + + private static void deleteIfExists(Path path) { + try { + if (!Files.exists(path)) { + return; + } + + if (Files.isDirectory(path)) { + try (Stream stream = Files.walk(path)) { + stream.sorted(Comparator.reverseOrder()).forEach(child -> { + try { + Files.deleteIfExists(child); + } catch (IOException var2) { + } + }); + } + } else { + Files.deleteIfExists(path); + } + } catch (IOException var6) { + } + } + + private record Artifact(String group, String artifact, String version, String sha256, long bytes) { + String fileName() { + return this.artifact + "-" + this.version + ".jar"; + } + + URI mavenUri() { + String groupPath = this.group.replace('.', '/'); + return URI.create("https://repo.maven.apache.org/maven2/" + groupPath + "/" + this.artifact + "/" + this.version + "/" + this.fileName()); + } + } + + private record DownloadedArtifact(NonIntifaceDependencyManager.Artifact artifact, byte[] bytes) { + } + + private enum InstallState { + IDLE, + DOWNLOADING, + BUILDING, + INSTALLED_RESTART_REQUIRED, + FAILED; + } +} diff --git a/src/main/java/com/nonid/client/particle/LiquidFallingParticle.java b/src/main/java/com/nonid/client/particle/LiquidFallingParticle.java new file mode 100644 index 0000000..b7ec877 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/LiquidFallingParticle.java @@ -0,0 +1,76 @@ +package com.nonid.client.particle; + +import com.nonid.NeedsOfNature; +import com.nonid.particle.NonParticles; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.Fluids; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.registry.Registries; +import net.minecraft.registry.tag.FluidTags; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.random.Random; + +public class LiquidFallingParticle extends LiquidLeakParticle { + public LiquidFallingParticle( + ClientWorld world, double x, double y, double z, Fluid fluid, Sprite sprite, int tintRgb, double velocityX, double velocityY, double velocityZ + ) { + super(world, x, y, z, fluid, sprite, tintRgb); + this.maxAge = (int)(64.0 / (Math.random() * 0.8 + 0.2)); + this.velocityX = velocityX; + this.velocityY = velocityY; + this.velocityZ = velocityZ; + } + + @Override + protected void updateVelocity() { + BlockPos pos = BlockPos.ofFloored(this.x, this.y, this.z); + if (this.world.getFluidState(pos).isIn(FluidTags.WATER)) { + this.markDead(); + double offsetX = (this.random.nextDouble() - 0.5) * 0.2; + double offsetZ = (this.random.nextDouble() - 0.5) * 0.2; + double offsetY = MathHelper.nextBetween(this.random, 0.01F, 0.04F); + ParticleEffect waterParticle = NonParticles.liquidWater(this.tintRgb); + this.world + .addParticleClient( + waterParticle, this.x + offsetX, this.y + offsetY, this.z + offsetZ, this.velocityX * 0.02, this.velocityY * 0.02, this.velocityZ * 0.02 + ); + } else { + if (this.onGround) { + SoundEvent sound = (SoundEvent)Registries.SOUND_EVENT.get(NeedsOfNature.id("wet04")); + float pitch = MathHelper.nextBetween(this.random, 0.3F, 1.0F); + this.world.playSoundClient(this.x, this.y, this.z, sound, SoundCategory.BLOCKS, 0.6F, pitch, false); + this.markDead(); + double offsetX = (this.random.nextDouble() - 0.5) * 0.3; + double offsetZ = (this.random.nextDouble() - 0.5) * 0.3; + double offsetY = MathHelper.nextBetween(this.random, 0.005F, 0.02F); + ParticleEffect puddleParticle = NonParticles.liquidPuddle(this.tintRgb); + this.world.addParticleClient(puddleParticle, this.x + offsetX, this.y + offsetY, this.z + offsetZ, 0.0, 0.0, 0.0); + } + } + } + + public static final class Factory implements ParticleFactory { + private final SpriteProvider spriteProvider; + + public Factory(SpriteProvider spriteProvider) { + this.spriteProvider = spriteProvider; + } + + public Particle createParticle( + TintedParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + return new LiquidFallingParticle( + world, x, y, z, Fluids.LAVA, this.spriteProvider.getSprite(random), LiquidLeakParticle.rgbFrom(effect), velocityX, velocityY, velocityZ + ); + } + } +} diff --git a/src/main/java/com/nonid/client/particle/LiquidLeakParticle.java b/src/main/java/com/nonid/client/particle/LiquidLeakParticle.java new file mode 100644 index 0000000..9751bea --- /dev/null +++ b/src/main/java/com/nonid/client/particle/LiquidLeakParticle.java @@ -0,0 +1,81 @@ +package com.nonid.client.particle; + +import net.minecraft.client.particle.BillboardParticle; +import net.minecraft.client.particle.BillboardParticle.RenderType; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.FluidState; +import net.minecraft.fluid.Fluids; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.util.math.BlockPos; + +abstract class LiquidLeakParticle extends BillboardParticle { + protected static final float BASE_GRAVITY = 0.06F; + protected static final double DAMPING = 0.98; + protected final Fluid fluid; + protected final int tintRgb; + + protected LiquidLeakParticle(ClientWorld world, double x, double y, double z, Fluid fluid, Sprite sprite, int tintRgb) { + super(world, x, y, z, sprite); + this.fluid = fluid; + this.tintRgb = tintRgb & 16777215; + this.setBoundingBoxSpacing(0.01F, 0.01F); + this.gravityStrength = 0.06F; + this.applyColor(this.tintRgb); + } + + public RenderType getRenderType() { + return RenderType.PARTICLE_ATLAS_OPAQUE; + } + + public void tick() { + this.lastX = this.x; + this.lastY = this.y; + this.lastZ = this.z; + this.updateAge(); + if (!this.dead) { + this.velocityY = this.velocityY - this.gravityStrength; + this.move(this.velocityX, this.velocityY, this.velocityZ); + this.updateVelocity(); + if (!this.dead) { + this.velocityX *= 0.98; + this.velocityY *= 0.98; + this.velocityZ *= 0.98; + if (this.fluid != Fluids.EMPTY) { + BlockPos pos = BlockPos.ofFloored(this.x, this.y, this.z); + FluidState fluidState = this.world.getFluidState(pos); + if (fluidState.getFluid() == this.fluid) { + double surface = pos.getY() + fluidState.getHeight(this.world, pos); + if (this.y < surface) { + this.markDead(); + } + } + } + } + } + } + + protected void updateAge() { + if (--this.maxAge <= 0) { + this.markDead(); + } + } + + protected void updateVelocity() { + } + + private void applyColor(int rgb) { + float r = (rgb >> 16 & 0xFF) / 255.0F; + float g = (rgb >> 8 & 0xFF) / 255.0F; + float b = (rgb & 0xFF) / 255.0F; + this.setColor(r, g, b); + } + + static int rgbFrom(TintedParticleEffect effect) { + int red = Math.max(0, Math.min(255, Math.round(effect.getRed() * 255.0F))); + int green = Math.max(0, Math.min(255, Math.round(effect.getGreen() * 255.0F))); + int blue = Math.max(0, Math.min(255, Math.round(effect.getBlue() * 255.0F))); + return red << 16 | green << 8 | blue; + } +} diff --git a/src/main/java/com/nonid/client/particle/LiquidParticle.java b/src/main/java/com/nonid/client/particle/LiquidParticle.java new file mode 100644 index 0000000..4eebc91 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/LiquidParticle.java @@ -0,0 +1,57 @@ +package com.nonid.client.particle; + +import com.nonid.particle.NonParticles; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.fluid.Fluid; +import net.minecraft.fluid.Fluids; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.util.math.random.Random; + +public class LiquidParticle extends LiquidLeakParticle { + private static final float GRAVITY_SCALE = 0.02F; + private static final int MAX_AGE_TICKS = 20; + private static final double VELOCITY_SCALE = 0.02; + private final ParticleEffect nextParticle; + + public LiquidParticle(ClientWorld world, double x, double y, double z, Fluid fluid, ParticleEffect nextParticle, Sprite sprite, int tintRgb) { + super(world, x, y, z, fluid, sprite, tintRgb); + this.nextParticle = nextParticle; + this.gravityStrength *= 0.02F; + this.maxAge = 20; + } + + @Override + protected void updateAge() { + if (--this.maxAge <= 0) { + this.markDead(); + this.world.addParticleClient(this.nextParticle, this.x, this.y, this.z, this.velocityX, this.velocityY, this.velocityZ); + } + } + + @Override + protected void updateVelocity() { + this.velocityX *= 0.02; + this.velocityY *= 0.02; + this.velocityZ *= 0.02; + } + + public static final class Factory implements ParticleFactory { + private final SpriteProvider spriteProvider; + + public Factory(SpriteProvider spriteProvider) { + this.spriteProvider = spriteProvider; + } + + public Particle createParticle( + TintedParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + int tintRgb = LiquidLeakParticle.rgbFrom(effect); + return new LiquidParticle(world, x, y, z, Fluids.LAVA, NonParticles.liquidFalling(tintRgb), this.spriteProvider.getSprite(random), tintRgb); + } + } +} diff --git a/src/main/java/com/nonid/client/particle/LiquidPuddleParticle.java b/src/main/java/com/nonid/client/particle/LiquidPuddleParticle.java new file mode 100644 index 0000000..df8a5d2 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/LiquidPuddleParticle.java @@ -0,0 +1,96 @@ +package com.nonid.client.particle; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import net.minecraft.client.particle.BillboardParticle; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.particle.BillboardParticle.RenderType; +import net.minecraft.client.particle.BillboardParticle.Rotator; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.random.Random; + +public class LiquidPuddleParticle extends BillboardParticle { + private static final int DEFAULT_MAX_AGE_TICKS = 800; + private static final int MAX_FADE_TICKS = 200; + private static final Rotator PUDDLE_ROTATOR = (quaternion, camera, tickDelta) -> quaternion.rotationXYZ((float) (-Math.PI / 2), 0.0F, 0.0F); + private final int holdTicks; + private final int fadeTicks; + + public LiquidPuddleParticle(ClientWorld world, double x, double y, double z, Sprite sprite, float scaleFactor, int tintRgb) { + super(world, x, y, z, sprite); + this.maxAge = resolvePuddleMaxAgeTicks(); + this.fadeTicks = Math.min(200, this.maxAge); + this.holdTicks = Math.max(0, this.maxAge - this.fadeTicks); + this.gravityStrength = 0.0F; + this.velocityX = 0.0; + this.velocityY = 0.0; + this.velocityZ = 0.0; + float sizeJitter = MathHelper.nextBetween(this.random, 0.8F, 1.0F); + this.scale = 0.5F * sizeJitter * Math.max(1.0F, scaleFactor); + this.alpha = 1.0F; + this.applyColor(tintRgb); + } + + public Rotator getRotator() { + return PUDDLE_ROTATOR; + } + + protected RenderType getRenderType() { + return RenderType.PARTICLE_ATLAS_TRANSLUCENT; + } + + public void tick() { + this.lastX = this.x; + this.lastY = this.y; + this.lastZ = this.z; + if (!this.hasSupportBlock()) { + this.markDead(); + } else if (++this.age >= this.maxAge) { + this.markDead(); + } else { + if (this.age > this.holdTicks && this.fadeTicks > 0) { + float fade = (float)(this.age - this.holdTicks) / this.fadeTicks; + this.alpha = MathHelper.clamp(1.0F - fade, 0.0F, 1.0F); + } else { + this.alpha = 1.0F; + } + } + } + + private static int resolvePuddleMaxAgeTicks() { + NonConfig config = NeedsOfNature.getConfig(); + return config == null ? 800 : Math.max(1, config.getLiquidPuddleDespawnSeconds() * 20); + } + + private boolean hasSupportBlock() { + BlockPos supportPos = BlockPos.ofFloored(this.x, this.y - 0.05, this.z); + return !this.world.getBlockState(supportPos).getCollisionShape(this.world, supportPos).isEmpty(); + } + + private void applyColor(int rgb) { + float r = (rgb >> 16 & 0xFF) / 255.0F; + float g = (rgb >> 8 & 0xFF) / 255.0F; + float b = (rgb & 0xFF) / 255.0F; + this.setColor(r, g, b); + } + + public static final class Factory implements ParticleFactory { + private final SpriteProvider spriteProvider; + + public Factory(SpriteProvider spriteProvider) { + this.spriteProvider = spriteProvider; + } + + public Particle createParticle( + TintedParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + return new LiquidPuddleParticle(world, x, y, z, this.spriteProvider.getSprite(random), 1.0F, LiquidLeakParticle.rgbFrom(effect)); + } + } +} diff --git a/src/main/java/com/nonid/client/particle/LiquidWaterParticle.java b/src/main/java/com/nonid/client/particle/LiquidWaterParticle.java new file mode 100644 index 0000000..8a8a0d0 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/LiquidWaterParticle.java @@ -0,0 +1,84 @@ +package com.nonid.client.particle; + +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.particle.BillboardParticle.RenderType; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.fluid.Fluids; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.util.math.random.Random; + +public class LiquidWaterParticle extends LiquidLeakParticle { + private static final int WATER_LIFE_TICKS = 100; + private static final float WATER_GRAVITY = 0.0018F; + private static final double WATER_DAMPING = 0.6; + private static final int WATER_FRAMES = 5; + + public LiquidWaterParticle( + ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Sprite sprite, int maxAge, int tintRgb + ) { + super(world, x, y, z, Fluids.EMPTY, sprite, tintRgb); + this.maxAge = Math.max(1, maxAge); + this.gravityStrength = 0.0018F; + this.velocityX = velocityX; + this.velocityY = velocityY; + this.velocityZ = velocityZ; + } + + @Override + public RenderType getRenderType() { + return RenderType.PARTICLE_ATLAS_OPAQUE; + } + + @Override + public void tick() { + this.lastX = this.x; + this.lastY = this.y; + this.lastZ = this.z; + if (this.age++ >= this.maxAge) { + this.markDead(); + } else { + this.velocityY = this.velocityY - this.gravityStrength; + this.move(this.velocityX, this.velocityY, this.velocityZ); + this.velocityX *= 0.6; + this.velocityY *= 0.6; + this.velocityZ *= 0.6; + } + } + + protected float getMinV() { + float minV = this.sprite.getMinV(); + float maxV = this.sprite.getMaxV(); + float frameSize = (maxV - minV) / 5.0F; + int frame = Math.min(4, (int)((float)this.age / this.maxAge * 5.0F)); + return minV + frame * frameSize; + } + + protected float getMaxV() { + float minV = this.sprite.getMinV(); + float maxV = this.sprite.getMaxV(); + float frameSize = (maxV - minV) / 5.0F; + int frame = Math.min(4, (int)((float)this.age / this.maxAge * 5.0F)); + return minV + (frame + 1) * frameSize; + } + + public static final class Factory implements ParticleFactory { + private final SpriteProvider spriteProvider; + + public Factory(SpriteProvider spriteProvider) { + this.spriteProvider = spriteProvider; + } + + public Particle createParticle( + TintedParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + int variance = random.nextInt(21) - 10; + int age = 100 + variance; + return new LiquidWaterParticle( + world, x, y, z, velocityX, velocityY, velocityZ, this.spriteProvider.getSprite(random), age, LiquidLeakParticle.rgbFrom(effect) + ); + } + } +} diff --git a/src/main/java/com/nonid/client/particle/RippedFabricParticle.java b/src/main/java/com/nonid/client/particle/RippedFabricParticle.java new file mode 100644 index 0000000..12097e6 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/RippedFabricParticle.java @@ -0,0 +1,92 @@ +package com.nonid.client.particle; + +import net.minecraft.client.particle.BillboardParticle; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.particle.BillboardParticle.RenderType; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.particle.SimpleParticleType; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.random.Random; + +public class RippedFabricParticle extends BillboardParticle { + private static final int FRAMES = 5; + private static final int TICKS_PER_FRAME = 4; + private static SpriteProvider sprites; + + public RippedFabricParticle( + ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Sprite sprite, int rgb, int maxAge, float scale + ) { + super(world, x, y, z, velocityX, velocityY, velocityZ, sprite); + this.velocityMultiplier = 0.82F; + this.gravityStrength = 0.1F; + this.maxAge = Math.max(1, maxAge); + this.scale = scale; + this.collidesWithWorld = true; + this.setBoundingBoxSpacing(0.01F, 0.01F); + this.setColor((rgb >> 16 & 0xFF) / 255.0F, (rgb >> 8 & 0xFF) / 255.0F, (rgb & 0xFF) / 255.0F); + } + + public static Particle create(ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, int rgb, Random random) { + SpriteProvider provider = sprites; + Sprite sprite = provider != null ? provider.getFirst() : null; + if (sprite == null) { + return null; + } + + int maxAge = 100 + random.nextInt(50); + float scale = 0.075F + random.nextFloat() * 0.045F; + return new RippedFabricParticle(world, x, y, z, velocityX, velocityY, velocityZ, sprite, rgb, maxAge, scale); + } + + public RenderType getRenderType() { + return RenderType.PARTICLE_ATLAS_OPAQUE; + } + + public void tick() { + super.tick(); + if (this.onGround) { + this.markDead(); + } + } + + public float getSize(float tickProgress) { + float life = (this.age + tickProgress) / this.maxAge; + float size = this.scale * MathHelper.clamp(life * 10.0F, 0.0F, 1.0F); + if (life > 0.75F) { + size *= MathHelper.lerp(MathHelper.clamp((life - 0.75F) / 0.25F, 0.0F, 1.0F), 1.0F, 0.15F); + } + + return size; + } + + protected float getMinV() { + float minV = this.sprite.getMinV(); + float maxV = this.sprite.getMaxV(); + float frameSize = (maxV - minV) / 5.0F; + int frame = this.age / 4 % 5; + return minV + frame * frameSize; + } + + protected float getMaxV() { + float minV = this.sprite.getMinV(); + float maxV = this.sprite.getMaxV(); + float frameSize = (maxV - minV) / 5.0F; + int frame = this.age / 4 % 5; + return minV + (frame + 1) * frameSize; + } + + public static final class Factory implements ParticleFactory { + public Factory(SpriteProvider spriteProvider) { + RippedFabricParticle.sprites = spriteProvider; + } + + public Particle createParticle( + SimpleParticleType type, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + return RippedFabricParticle.create(world, x, y, z, velocityX, velocityY, velocityZ, 15920872, random); + } + } +} diff --git a/src/main/java/com/nonid/client/particle/SmallHeartParticle.java b/src/main/java/com/nonid/client/particle/SmallHeartParticle.java new file mode 100644 index 0000000..7d5d437 --- /dev/null +++ b/src/main/java/com/nonid/client/particle/SmallHeartParticle.java @@ -0,0 +1,56 @@ +package com.nonid.client.particle; + +import net.minecraft.client.particle.BillboardParticle; +import net.minecraft.client.particle.Particle; +import net.minecraft.client.particle.ParticleFactory; +import net.minecraft.client.particle.SpriteProvider; +import net.minecraft.client.particle.BillboardParticle.RenderType; +import net.minecraft.client.texture.Sprite; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.particle.SimpleParticleType; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.random.Random; + +public class SmallHeartParticle extends BillboardParticle { + protected SmallHeartParticle(ClientWorld world, double x, double y, double z, Sprite sprite) { + super(world, x, y, z, 0.0, 0.0, 0.0, sprite); + this.ascending = true; + this.velocityMultiplier = 0.86F; + this.velocityX *= 0.01F; + this.velocityY *= 0.01F; + this.velocityZ *= 0.01F; + this.velocityY += 0.1; + this.scale = 0.145F + this.random.nextFloat() * 0.08F; + this.maxAge = 16; + this.collidesWithWorld = false; + } + + public RenderType getRenderType() { + return RenderType.PARTICLE_ATLAS_OPAQUE; + } + + public float getSize(float tickProgress) { + float life = (this.age + tickProgress) / this.maxAge; + float base = this.scale * MathHelper.clamp(life * 32.0F, 0.0F, 1.0F); + if (life > 0.8F) { + float fade = MathHelper.clamp((life - 0.8F) / 0.2F, 0.0F, 1.0F); + base *= MathHelper.lerp(fade, 1.0F, 0.8F); + } + + return base; + } + + public static final class Factory implements ParticleFactory { + private final SpriteProvider spriteProvider; + + public Factory(SpriteProvider spriteProvider) { + this.spriteProvider = spriteProvider; + } + + public Particle createParticle( + SimpleParticleType type, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, Random random + ) { + return new SmallHeartParticle(world, x, y, z, this.spriteProvider.getSprite(random)); + } + } +} diff --git a/src/main/java/com/nonid/data/NonAccessoryBehavior.java b/src/main/java/com/nonid/data/NonAccessoryBehavior.java new file mode 100644 index 0000000..d93ee33 --- /dev/null +++ b/src/main/java/com/nonid/data/NonAccessoryBehavior.java @@ -0,0 +1,76 @@ +package com.nonid.data; + +import java.util.Locale; +import java.util.Set; +import org.jetbrains.annotations.Nullable; + +public record NonAccessoryBehavior( + Set occupiesSlots, + Set blocksInjectorTypes, + @Nullable String exclusiveGroup, + int maxDurability, + int protectionDurabilityCost, + boolean ignoreInjectorSlotVisualShedding, + boolean ignoreInjectorSlotEffectSuppression, + int vInjectionDurabilityCost, + int aInjectionDurabilityCost, + boolean blocksPregnancy +) { + public static final NonAccessoryBehavior NONE = new NonAccessoryBehavior(Set.of(), Set.of(), null, 0, 1, false, false, 0, 0, false); + + public boolean isEmpty() { + return this.occupiesSlots.isEmpty() + && this.blocksInjectorTypes.isEmpty() + && (this.exclusiveGroup == null || this.exclusiveGroup.isBlank()) + && !this.ignoreInjectorSlotVisualShedding + && !this.ignoreInjectorSlotEffectSuppression + && this.vInjectionDurabilityCost <= 0 + && this.aInjectionDurabilityCost <= 0 + && !this.blocksPregnancy; + } + + public boolean occupiesSlot(String slotId) { + if (slotId == null) { + return false; + } + + String normalized = normalizeSlot(slotId); + + for (String occupied : this.occupiesSlots) { + if (isSlot(normalized, occupied)) { + return true; + } + } + + return false; + } + + public boolean blocks(NonLiquidRoles.InjectorRole role) { + return role != null && this.blocksInjectorTypes.contains(role); + } + + public static String normalizeSlot(String slotId) { + if (slotId == null) { + return ""; + } else { + String slot = slotId.trim().toLowerCase(Locale.ROOT); + if ("v".equals(slot)) { + return "legs/v"; + } else if ("a".equals(slot)) { + return "legs/a"; + } else { + return "d".equals(slot) ? "legs/d" : slot; + } + } + } + + public static boolean isSlot(String actual, String expected) { + if (actual != null && expected != null) { + String normalizedActual = normalizeSlot(actual); + String normalizedExpected = normalizeSlot(expected); + return normalizedActual.equals(normalizedExpected) || normalizedActual.startsWith(normalizedExpected + "/"); + } else { + return false; + } + } +} diff --git a/src/main/java/com/nonid/data/NonAccessoryDefinitions.java b/src/main/java/com/nonid/data/NonAccessoryDefinitions.java new file mode 100644 index 0000000..32eb6d5 --- /dev/null +++ b/src/main/java/com/nonid/data/NonAccessoryDefinitions.java @@ -0,0 +1,316 @@ +package com.nonid.data; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.nonid.NeedsOfNature; +import com.nonid.NonAccessoryEffects; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAccessoryDefinitions { + private static volatile Map DEFINITIONS_BY_ITEM = Map.of(); + + private NonAccessoryDefinitions() { + } + + public static void loadStartupDefinitions() { + DEFINITIONS_BY_ITEM = NonAccessoryItemRegistry.getDefinitionsByItem(); + } + + public static NonAccessoryEffects getEffects(Identifier itemId) { + if (itemId == null) { + return NonAccessoryEffects.NEUTRAL; + } + + NonAccessoryItemRegistry.AccessoryItemDefinition definition = DEFINITIONS_BY_ITEM.get(itemId); + return definition == null ? NonAccessoryEffects.NEUTRAL : definition.effects(); + } + + public static boolean hasDefinition(Identifier itemId) { + return itemId != null && DEFINITIONS_BY_ITEM.containsKey(itemId); + } + + public static NonAccessoryBehavior getBehavior(Identifier itemId) { + if (itemId == null) { + return NonAccessoryBehavior.NONE; + } + + NonAccessoryItemRegistry.AccessoryItemDefinition definition = DEFINITIONS_BY_ITEM.get(itemId); + return definition == null ? NonAccessoryBehavior.NONE : definition.behavior(); + } + + @Nullable + public static NonAccessoryDefinitions.TooltipColor getTooltipColor(Identifier itemId, String effectKey) { + if (itemId != null && effectKey != null) { + NonAccessoryItemRegistry.AccessoryItemDefinition definition = DEFINITIONS_BY_ITEM.get(itemId); + Map colors = definition == null ? null : definition.tooltipColors(); + return colors == null ? null : colors.get(effectKey); + } else { + return null; + } + } + + public static NonAccessoryEffects parseEffectsForStartup(Identifier itemId, JsonObject root) { + JsonObject effects = objectOrSelf(root.get("effects"), root); + return parseEffectsObject(itemId, effects); + } + + public static Map parseTooltipColorsForStartup(Identifier itemId, JsonObject root) { + JsonElement effectsElement = root.get("effects"); + if (effectsElement != null && effectsElement.isJsonObject()) { + JsonObject effects = effectsElement.getAsJsonObject(); + return parseInlineTooltipColors(itemId, effects); + } else { + return Map.of(); + } + } + + public static NonAccessoryBehavior parseBehaviorForStartup(Identifier itemId, JsonObject root) { + return parseBehavior(itemId, root); + } + + private static NonAccessoryEffects parseEffectsObject(Identifier fileId, JsonObject effects) { + return new NonAccessoryEffects( + parseMultiplier(fileId, effects, "liquid_decay_multiplier", 1.0, 0.0, 20.0), + parseBoolean(effects, "equalize_liquid_decay_context", false), + parseMultiplier(fileId, effects, "player_energy_gain_multiplier", 1.0, 0.0, 20.0), + parseInt(fileId, effects, "liquid_capacity_add", 0, -10000, 10000), + parseMultiplier(fileId, effects, "liquid_gain_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "filled_effect_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "pregnancy_chance_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "pregnancy_duration_multiplier", 1.0, 0.01, 20.0), + parseMultiplier(fileId, effects, "mess_gain_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "destroyed_skin_damage_multiplier", 1.0, 0.0, 20.0), + parseInt(fileId, effects, "attack_escape_hits_add", 0, -49, 49), + parseMultiplier(fileId, effects, "attack_escape_damage_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "player_energy_aura_multiplier", 1.0, 0.0, 20.0), + parseMultiplier(fileId, effects, "near_animation_mob_energy_multiplier", 1.0, 0.0, 20.0) + ); + } + + private static JsonObject objectOrSelf(@Nullable JsonElement element, JsonObject fallback) { + return element != null && element.isJsonObject() ? element.getAsJsonObject() : fallback; + } + + private static NonAccessoryBehavior parseBehavior(Identifier fileId, JsonObject root) { + Set occupiesSlots = parseSlotSet(root.get("occupies_slots")); + Set blockedRoles = parseInjectorRoleSet(fileId, root.get("blocks_injector_types")); + String exclusiveGroup = parseOptionalString(root.get("exclusive_group")); + int maxDurability = parseInt(fileId, root, "max_durability", 0, 0, 100000); + int durabilityCost = parseInt(fileId, root, "protection_durability_cost", 1, 1, 100); + boolean ignoreVisualShedding = parseBoolean(root, "ignore_injector_slot_visual_shedding", false); + boolean ignoreEffectSuppression = parseBoolean(root, "ignore_injector_slot_effect_suppression", false); + int vInjectionDurabilityCost = parseInt(fileId, root, "v_injection_durability_cost", 0, 0, 100); + int aInjectionDurabilityCost = parseInt(fileId, root, "a_injection_durability_cost", 0, 0, 100); + boolean blocksPregnancy = parseBoolean(root, "blocks_pregnancy", false); + return new NonAccessoryBehavior( + occupiesSlots, + blockedRoles, + exclusiveGroup, + maxDurability, + durabilityCost, + ignoreVisualShedding, + ignoreEffectSuppression, + vInjectionDurabilityCost, + aInjectionDurabilityCost, + blocksPregnancy + ); + } + + private static Set parseSlotSet(@Nullable JsonElement element) { + if (element == null) { + return Set.of(); + } + + LinkedHashSet slots = new LinkedHashSet<>(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + addSlot(slots, parseOptionalString(child)); + } + } else { + addSlot(slots, parseOptionalString(element)); + } + + return Set.copyOf(slots); + } + + private static void addSlot(Set slots, @Nullable String raw) { + if (raw != null && !raw.isBlank()) { + String slot = NonAccessoryBehavior.normalizeSlot(raw); + if (!"legs/v".equals(slot) && !"legs/a".equals(slot) && !"legs/d".equals(slot)) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring unknown accessory occupied slot '{}'.", raw); + } else { + slots.add(slot); + } + } + } + + private static Set parseInjectorRoleSet(Identifier fileId, @Nullable JsonElement element) { + if (element == null) { + return Set.of(); + } + + LinkedHashSet roles = new LinkedHashSet<>(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + addInjectorRole(fileId, roles, parseOptionalString(child)); + } + } else { + addInjectorRole(fileId, roles, parseOptionalString(element)); + } + + return Set.copyOf(roles); + } + + private static void addInjectorRole(Identifier fileId, Set roles, @Nullable String raw) { + if (raw != null && !raw.isBlank()) { + String normalized = raw.trim().toUpperCase(Locale.ROOT); + + try { + NonLiquidRoles.InjectorRole role = NonLiquidRoles.InjectorRole.valueOf(normalized); + if (role != NonLiquidRoles.InjectorRole.NONE) { + roles.add(role); + } + } catch (IllegalArgumentException ignored) { + warn(fileId, "Ignoring unknown blocked injector type '" + raw + "'."); + } + } + } + + @Nullable + private static String parseOptionalString(@Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString(); + return raw != null && !raw.isBlank() ? raw.trim() : null; + } else { + return null; + } + } + + private static double parseMultiplier(Identifier fileId, JsonObject object, String key, double fallback, double min, double max) { + Double value = parseDouble(object.get(key)); + if (value == null) { + return fallback; + } else if (!Double.isFinite(value)) { + warn(fileId, "Ignoring non-finite '" + key + "'."); + return fallback; + } else { + return Math.max(min, Math.min(max, value)); + } + } + + private static int parseInt(Identifier fileId, JsonObject object, String key, int fallback, int min, int max) { + Double value = parseDouble(object.get(key)); + if (value == null) { + return fallback; + } + + if (Double.isFinite(value) && value == Math.rint(value)) { + return Math.max(min, Math.min(max, value.intValue())); + } + + warn(fileId, "Ignoring non-integer '" + key + "'."); + return fallback; + } + + @Nullable + private static Double parseDouble(@Nullable JsonElement element) { + element = unwrapEffectValue(element); + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return primitive.isNumber() ? primitive.getAsDouble() : null; + } else { + return null; + } + } + + private static boolean parseBoolean(JsonObject object, String key, boolean fallback) { + JsonElement element = unwrapEffectValue(object.get(key)); + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return primitive.isBoolean() ? primitive.getAsBoolean() : fallback; + } else { + return fallback; + } + } + + @Nullable + private static JsonElement unwrapEffectValue(@Nullable JsonElement element) { + return element != null && element.isJsonObject() ? element.getAsJsonObject().get("value") : element; + } + + private static Map parseInlineTooltipColors(Identifier fileId, JsonObject effects) { + LinkedHashMap colors = new LinkedHashMap<>(); + + for (Entry entry : effects.entrySet()) { + JsonElement value = entry.getValue(); + if (value != null && value.isJsonObject()) { + JsonElement tooltip = value.getAsJsonObject().get("tooltip"); + NonAccessoryDefinitions.TooltipColor color = parseTooltipColor(fileId, entry.getKey(), tooltip); + if (color != null) { + colors.put(entry.getKey(), color); + } + } + } + + return Map.copyOf(colors); + } + + @Nullable + private static NonAccessoryDefinitions.TooltipColor parseTooltipColor(Identifier fileId, String key, @Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString().trim(); + if (raw.isEmpty()) { + return null; + } + + if ("positive".equalsIgnoreCase(raw)) { + return NonAccessoryDefinitions.TooltipColor.formatting(Formatting.GREEN); + } + + if ("negative".equalsIgnoreCase(raw)) { + return NonAccessoryDefinitions.TooltipColor.formatting(Formatting.RED); + } + + if ("neutral".equalsIgnoreCase(raw)) { + return NonAccessoryDefinitions.TooltipColor.formatting(Formatting.WHITE); + } + + String hex = raw.startsWith("#") ? raw.substring(1) : raw; + if (hex.length() == 6) { + try { + return NonAccessoryDefinitions.TooltipColor.rgb(Integer.parseInt(hex, 16)); + } catch (NumberFormatException var6) { + } + } + + warn(fileId, "Ignoring unknown tooltip color '" + raw + "' for '" + key + "'."); + return null; + } else { + warn(fileId, "Ignoring invalid tooltip color for '" + key + "'."); + return null; + } + } + + private static void warn(Identifier fileId, String reason) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring accessory definition {}: {}", fileId, reason); + } + + public record TooltipColor(@Nullable Formatting formatting, @Nullable Integer rgb) { + public static NonAccessoryDefinitions.TooltipColor formatting(Formatting formatting) { + return new NonAccessoryDefinitions.TooltipColor(formatting, null); + } + + public static NonAccessoryDefinitions.TooltipColor rgb(int rgb) { + return new NonAccessoryDefinitions.TooltipColor(null, rgb & 16777215); + } + } +} diff --git a/src/main/java/com/nonid/data/NonAccessoryItemRegistry.java b/src/main/java/com/nonid/data/NonAccessoryItemRegistry.java new file mode 100644 index 0000000..478c025 --- /dev/null +++ b/src/main/java/com/nonid/data/NonAccessoryItemRegistry.java @@ -0,0 +1,634 @@ +package com.nonid.data; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.nonid.NeedsOfNature; +import com.nonid.NonAccessoryEffects; +import com.nonid.NonTrinketsIntegration; +import com.nonid.item.NonDataAccessoryItem; +import com.nonid.pack.NonExternalPackCatalog; +import com.nonid.pack.NonExternalPackProvider; +import com.nonid.pack.NonPackRootResolver; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.DyedColorComponent; +import net.minecraft.item.Item; +import net.minecraft.item.Item.Settings; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.registry.RegistryKey; +import net.minecraft.util.DyeColor; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAccessoryItemRegistry { + private static final String ITEM_ROOT = "assets/needsofnature/non_accessory_items"; + private static final int DURABLE_ACCESSORY_ENCHANTABILITY = 10; + private static final List REGISTERED_ITEMS = new ArrayList<>(); + private static final Map DEFINITIONS_BY_ITEM = new LinkedHashMap<>(); + private static final Map> ITEM_IDS_BY_TRINKETS_SLOT = new LinkedHashMap<>(); + + private NonAccessoryItemRegistry() { + } + + public static void registerStartupItems() { + if (REGISTERED_ITEMS.isEmpty()) { + if (!NonTrinketsIntegration.isTrinketsLoaded()) { + NeedsOfNature.LOGGER.info("[NoN] Trinkets is not installed; skipping data-driven accessory item registration."); + } else { + LinkedHashMap definitions = new LinkedHashMap<>(); + loadBundledDefinitions(definitions); + loadExternalDefinitions(definitions); + definitions.values().forEach(NonAccessoryItemRegistry::register); + NeedsOfNature.LOGGER.info("[NoN] Registered {} startup data-driven accessory items.", REGISTERED_ITEMS.size()); + } + } + } + + public static List getRegisteredItems() { + return List.copyOf(REGISTERED_ITEMS); + } + + public static Map getDefinitionsByItem() { + return Map.copyOf(DEFINITIONS_BY_ITEM); + } + + public static List getItemIdsForTrinketsSlot(String slotId) { + return slotId == null ? List.of() : ITEM_IDS_BY_TRINKETS_SLOT.getOrDefault(slotId, List.of()); + } + + public static NonAccessoryItemRegistry.SkinOverlays getSkinOverlays(Identifier itemId) { + if (itemId == null) { + return NonAccessoryItemRegistry.SkinOverlays.EMPTY; + } + + NonAccessoryItemRegistry.AccessoryItemDefinition definition = DEFINITIONS_BY_ITEM.get(itemId); + return definition == null ? NonAccessoryItemRegistry.SkinOverlays.EMPTY : definition.skinOverlays(); + } + + public static Map getClientModelsByItem() { + LinkedHashMap models = new LinkedHashMap<>(); + + for (NonAccessoryItemRegistry.AccessoryItemDefinition definition : DEFINITIONS_BY_ITEM.values()) { + if (!definition.clientModel().isEmpty()) { + models.put(definition.id(), definition.clientModel()); + } + } + + return Map.copyOf(models); + } + + private static void loadBundledDefinitions(Map definitions) { + FabricLoader.getInstance().getModContainer("needsofnature").ifPresent(container -> { + for (Path root : container.getRootPaths()) { + readDefinitionsFromDirectory(root.resolve("assets/needsofnature/non_accessory_items"), definitions); + } + }); + } + + private static void loadExternalDefinitions(Map definitions) { + Path packsDir = NonExternalPackProvider.resolveDefaultPacksDir(); + List configuredOrder = NeedsOfNature.getConfig() == null ? List.of() : NeedsOfNature.getConfig().getExternalNoNPackLoadOrder(); + + for (NonExternalPackCatalog.Entry entry : NonExternalPackCatalog.resolveLowToHigh(packsDir, configuredOrder)) { + readDefinitionsFromPack(entry.root(), definitions); + } + } + + @Nullable + public static NonAccessoryItemRegistry.AccessoryItemDefinition getDefinition(Identifier itemId) { + return itemId == null ? null : DEFINITIONS_BY_ITEM.get(itemId); + } + + public static List getDyeableItemIds() { + return DEFINITIONS_BY_ITEM.values() + .stream() + .filter(NonAccessoryItemRegistry.AccessoryItemDefinition::dyeable) + .map(NonAccessoryItemRegistry.AccessoryItemDefinition::id) + .toList(); + } + + public static List getRegisteredItemIds() { + return DEFINITIONS_BY_ITEM.values().stream().map(NonAccessoryItemRegistry.AccessoryItemDefinition::id).toList(); + } + + public static List getDurabilityEnchantableItemIds() { + return DEFINITIONS_BY_ITEM.values() + .stream() + .filter(definition -> supportsDurabilityEnchantments(definition.maxDurability())) + .map(NonAccessoryItemRegistry.AccessoryItemDefinition::id) + .toList(); + } + + static boolean supportsDurabilityEnchantments(int maxDurability) { + return maxDurability > 1; + } + + private static void readDefinitionsFromPack( + NonPackRootResolver.ResolvedRoot root, Map definitions + ) { + if (!root.isZip()) { + readDefinitionsFromDirectory(root.rootPath().resolve("assets/needsofnature/non_accessory_items"), definitions); + } else { + Path path = root.sourcePath(); + + try (ZipFile zip = new ZipFile(path.toFile())) { + zip.stream() + .filter(entry -> !entry.isDirectory()) + .filter(entry -> { + String name = root.stripZipPrefix(entry.getName()); + return name != null && name.startsWith("assets/needsofnature/non_accessory_items/") && name.endsWith(".json"); + }) + .sorted(Comparator.comparing(ZipEntry::getName, String.CASE_INSENSITIVE_ORDER)) + .forEach(entry -> readDefinitionFromZip(path, root, zip, entry, definitions)); + } catch (IOException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to scan accessory item pack {}", path, e); + } + } + } + + private static void readDefinitionsFromDirectory(Path dir, Map definitions) { + if (Files.isDirectory(dir)) { + try (Stream paths = Files.list(dir)) { + paths.filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER)) + .forEach(path -> readDefinitionFromPath(path, definitions)); + } catch (IOException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to scan accessory item definitions in {}", dir, e); + } + } + } + + private static void readDefinitionFromPath(Path path, Map definitions) { + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + readDefinition(path.toString(), JsonParser.parseReader(reader).getAsJsonObject(), definitions); + } catch (IOException | RuntimeException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to read accessory item definition {}", path, e); + } + } + + private static void readDefinitionFromZip( + Path zipPath, + NonPackRootResolver.ResolvedRoot root, + ZipFile zip, + ZipEntry entry, + Map definitions + ) { + String logicalPath = root.stripZipPrefix(entry.getName()); + if (logicalPath != null) { + try (InputStreamReader reader = new InputStreamReader(zip.getInputStream(entry), StandardCharsets.UTF_8)) { + readDefinition(zipPath + "!" + logicalPath, JsonParser.parseReader(reader).getAsJsonObject(), definitions); + } catch (IOException | RuntimeException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to read accessory item definition {}!{}", zipPath, logicalPath, e); + } + } + } + + private static void readDefinition(String source, JsonObject root, Map definitions) { + Identifier id = parseItemId(root); + if (id == null) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring accessory item definition {}: missing or invalid id.", source); + } else if (!"needsofnature".equals(id.getNamespace())) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring accessory item definition {}: namespace must be '{}'.", source, "needsofnature"); + } else if (definitions.containsKey(id)) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring duplicate accessory item definition {} for {}.", source, id); + } else { + int maxCount = parseInt(root.get("max_count"), 1, 1, 64); + int maxDurability = parseInt(root.get("max_durability"), 0, 0, 100000); + if (maxDurability > 0) { + maxCount = 1; + } + + boolean showInItemGroup = parseBoolean(root.get("show_in_item_group"), true); + Set trinketsSlots = parseTrinketsSlots(root.get("trinkets_slots")); + if (trinketsSlots.isEmpty()) { + trinketsSlots = Set.of("legs/v", "legs/a"); + } + + NonAccessoryEffects effects = root.has("effects") ? NonAccessoryDefinitions.parseEffectsForStartup(id, root) : NonAccessoryEffects.NEUTRAL; + Map tooltipColors = NonAccessoryDefinitions.parseTooltipColorsForStartup(id, root); + NonAccessoryBehavior behavior = NonAccessoryDefinitions.parseBehaviorForStartup(id, root); + NonAccessoryItemRegistry.SkinOverlays skinOverlays = parseSkinOverlays(id, root.get("skin_overlays")); + NonAccessoryItemRegistry.ItemClientModel clientModel = parseItemClientModel(id, root); + boolean dyeable = parseBoolean(root.get("dyeable"), false); + int defaultDyeColor = parseDyeColor(root.get("default_dye_color")); + NonAccessoryItemRegistry.ActorVisualBehavior actorVisualBehavior = parseActorVisualBehavior(id, root); + definitions.put( + id, + new NonAccessoryItemRegistry.AccessoryItemDefinition( + id, + maxCount, + maxDurability, + showInItemGroup, + trinketsSlots, + effects, + tooltipColors, + behavior, + skinOverlays, + clientModel, + dyeable, + defaultDyeColor, + actorVisualBehavior + ) + ); + } + } + + private static void register(NonAccessoryItemRegistry.AccessoryItemDefinition definition) { + RegistryKey key = RegistryKey.of(Registries.ITEM.getKey(), definition.id()); + Settings settings = new Settings().registryKey(key).maxCount(definition.maxCount()); + if (definition.maxDurability() > 0) { + settings.maxDamage(definition.maxDurability()); + settings.component(DataComponentTypes.MAX_DAMAGE, definition.maxDurability()); + settings.component(DataComponentTypes.DAMAGE, 0); + } + + if (supportsDurabilityEnchantments(definition.maxDurability())) { + settings.enchantable(10); + } + + if (definition.dyeable()) { + settings.component(DataComponentTypes.DYED_COLOR, new DyedColorComponent(definition.defaultDyeColor())); + } + + Item item = new NonDataAccessoryItem(settings); + Registry.register(Registries.ITEM, key, item); + NonTrinketsIntegration.registerTrinketItem(item); + if (definition.showInItemGroup()) { + REGISTERED_ITEMS.add(item); + } + + DEFINITIONS_BY_ITEM.put(definition.id(), definition); + + for (String slot : definition.trinketsSlots()) { + List ids = new ArrayList<>(ITEM_IDS_BY_TRINKETS_SLOT.getOrDefault(slot, List.of())); + ids.add(definition.id()); + ITEM_IDS_BY_TRINKETS_SLOT.put(slot, List.copyOf(ids)); + } + } + + private static NonAccessoryItemRegistry.SkinOverlays parseSkinOverlays(Identifier itemId, @Nullable JsonElement element) { + if (element != null && element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + List v = parseVisualLayers(itemId, "v", object.get("v"), true); + List a = parseVisualLayers(itemId, "a", object.get("a"), true); + List d = parseVisualLayers(itemId, "d", object.get("d"), true); + return new NonAccessoryItemRegistry.SkinOverlays(v, a, d); + } else { + return NonAccessoryItemRegistry.SkinOverlays.EMPTY; + } + } + + private static NonAccessoryItemRegistry.ItemClientModel parseItemClientModel(Identifier itemId, JsonObject root) { + Identifier itemTexture = parseAssetIdentifier(itemId, root.get("item_texture"), "item/"); + Identifier itemModel = parseAssetIdentifier(itemId, root.get("item_model"), "item/"); + List layers = parseVisualLayers(itemId, "item_layers", root.get("item_layers"), false); + if (layers.isEmpty() && itemTexture != null) { + layers = List.of(new NonAccessoryItemRegistry.VisualLayer(itemTexture, NonAccessoryItemRegistry.TintMode.NONE)); + } + + if (itemModel == null && !layers.isEmpty()) { + itemModel = Identifier.of(itemId.getNamespace(), "item/" + itemId.getPath()); + } + + return new NonAccessoryItemRegistry.ItemClientModel(itemModel, layers); + } + + private static NonAccessoryItemRegistry.ActorVisualBehavior parseActorVisualBehavior(Identifier itemId, JsonObject root) { + Set virtualTags = parseStringSet(root.get("virtual_actor_tags")); + String modelGender = parseString(root.get("animation_model_gender")); + if (modelGender != null) { + modelGender = modelGender.trim().toLowerCase(Locale.ROOT); + if (!"male".equals(modelGender) && !"female".equals(modelGender)) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring unknown animation model gender '{}' for {}.", modelGender, itemId); + modelGender = null; + } + } + + Map boneTextures = new LinkedHashMap<>(); + JsonElement boneElement = root.get("animation_bone_textures"); + if (boneElement != null && boneElement.isJsonObject()) { + for (Entry entry : boneElement.getAsJsonObject().entrySet()) { + List layers = parseVisualLayers(itemId, entry.getKey(), entry.getValue(), true); + if (!layers.isEmpty()) { + boneTextures.put(entry.getKey(), layers.getFirst()); + } + } + } + + int storedLiquidCapacityMl = parseInt(root.get("stored_liquid_capacity_ml"), 0, 0, 1000); + return new NonAccessoryItemRegistry.ActorVisualBehavior(virtualTags, modelGender, Map.copyOf(boneTextures), storedLiquidCapacityMl); + } + + private static List parseVisualLayers( + Identifier itemId, String key, @Nullable JsonElement element, boolean texturePath + ) { + if (element == null) { + return List.of(); + } + + List layers = new ArrayList<>(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + NonAccessoryItemRegistry.VisualLayer layer = parseVisualLayer(itemId, key, child, texturePath); + if (layer != null) { + layers.add(layer); + } + } + } else { + NonAccessoryItemRegistry.VisualLayer layer = parseVisualLayer(itemId, key, element, texturePath); + if (layer != null) { + layers.add(layer); + } + } + + return layers.isEmpty() ? List.of() : List.copyOf(layers); + } + + @Nullable + private static NonAccessoryItemRegistry.VisualLayer parseVisualLayer(Identifier itemId, String key, JsonElement element, boolean texturePath) { + JsonElement textureElement = element; + NonAccessoryItemRegistry.TintMode tintMode = NonAccessoryItemRegistry.TintMode.NONE; + if (element != null && element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + textureElement = object.get("texture"); + tintMode = NonAccessoryItemRegistry.TintMode.parse(parseString(object.get("tint"))); + } + + Identifier texture = texturePath ? parseTextureIdentifier(itemId, key, textureElement) : parseAssetIdentifier(itemId, textureElement, "item/"); + return texture == null ? null : new NonAccessoryItemRegistry.VisualLayer(texture, tintMode); + } + + private static Set parseStringSet(@Nullable JsonElement element) { + if (element == null) { + return Set.of(); + } + + LinkedHashSet values = new LinkedHashSet<>(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + String value = parseString(child); + if (value != null && !value.isBlank()) { + values.add(value.trim()); + } + } + } else { + String value = parseString(element); + if (value != null && !value.isBlank()) { + values.add(value.trim()); + } + } + + return Set.copyOf(values); + } + + private static int parseDyeColor(@Nullable JsonElement element) { + String raw = parseString(element); + DyeColor color = DyeColor.byId(raw == null ? "purple" : raw.trim().toLowerCase(Locale.ROOT), DyeColor.PURPLE); + return color.getEntityColor(); + } + + @Nullable + private static Identifier parseAssetIdentifier(Identifier itemId, @Nullable JsonElement element, String defaultPathPrefix) { + String raw = parseString(element); + if (raw != null && !raw.isBlank()) { + raw = raw.trim(); + if (!raw.contains(":")) { + raw = itemId.getNamespace() + ":" + defaultPathPrefix + raw; + } + + Identifier id = Identifier.tryParse(raw); + if (id == null) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring invalid accessory asset identifier '{}' for {}.", raw, itemId); + } + + return id; + } else { + return null; + } + } + + @Nullable + private static Identifier parseTextureIdentifier(Identifier itemId, String key, @Nullable JsonElement element) { + String raw = parseString(element); + if (raw != null && !raw.isBlank()) { + raw = raw.trim(); + if (!raw.contains(":")) { + raw = itemId.getNamespace() + ":" + raw; + } + + Identifier id = Identifier.tryParse(raw); + if (id == null) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring invalid accessory skin overlay '{}' for {}.", key, itemId); + return null; + } else { + return toTexturePath(id); + } + } else { + return null; + } + } + + private static Identifier toTexturePath(Identifier id) { + String path = id.getPath(); + if (!path.startsWith("textures/")) { + path = "textures/" + path; + } + + if (!path.endsWith(".png")) { + path = path + ".png"; + } + + return Identifier.of(id.getNamespace(), path); + } + + private static Set parseTrinketsSlots(@Nullable JsonElement element) { + if (element == null) { + return Set.of(); + } + + LinkedHashSet slots = new LinkedHashSet<>(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + addTrinketsSlot(slots, parseString(child)); + } + } else { + addTrinketsSlot(slots, parseString(element)); + } + + return Set.copyOf(slots); + } + + private static void addTrinketsSlot(Set slots, @Nullable String raw) { + if (raw != null && !raw.isBlank()) { + String slot = raw.trim().toLowerCase(Locale.ROOT); + if ("v".equals(slot)) { + slot = "legs/v"; + } + + if ("a".equals(slot)) { + slot = "legs/a"; + } + + if ("d".equals(slot)) { + slot = "legs/d"; + } + + if (!"legs/v".equals(slot) && !"legs/a".equals(slot) && !"legs/d".equals(slot)) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring unknown accessory Trinkets slot '{}'.", raw); + } else { + slots.add(slot); + } + } + } + + @Nullable + private static Identifier parseItemId(JsonObject root) { + String raw = parseString(root.get("id")); + if (raw != null && !raw.isBlank()) { + raw = raw.trim(); + if (!raw.contains(":")) { + raw = "needsofnature:" + raw; + } + + return Identifier.tryParse(raw); + } else { + return null; + } + } + + @Nullable + private static String parseString(@Nullable JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString() ? element.getAsString() : null; + } + + private static int parseInt(@Nullable JsonElement element, int fallback, int min, int max) { + if (element == null || !element.isJsonPrimitive()) { + return fallback; + } + + if (!element.getAsJsonPrimitive().isNumber()) { + return fallback; + } + + try { + int value = element.getAsInt(); + return Math.max(min, Math.min(max, value)); + } catch (RuntimeException ignored) { + return fallback; + } + } + + private static boolean parseBoolean(@Nullable JsonElement element, boolean fallback) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return primitive.isBoolean() ? primitive.getAsBoolean() : fallback; + } else { + return fallback; + } + } + + public record AccessoryItemDefinition( + Identifier id, + int maxCount, + int maxDurability, + boolean showInItemGroup, + Set trinketsSlots, + NonAccessoryEffects effects, + Map tooltipColors, + NonAccessoryBehavior behavior, + NonAccessoryItemRegistry.SkinOverlays skinOverlays, + NonAccessoryItemRegistry.ItemClientModel clientModel, + boolean dyeable, + int defaultDyeColor, + NonAccessoryItemRegistry.ActorVisualBehavior actorVisualBehavior + ) { + } + + public record ActorVisualBehavior( + Set virtualActorTags, + @Nullable String animationModelGender, + Map animationBoneTextures, + int storedLiquidCapacityMl + ) { + public static final NonAccessoryItemRegistry.ActorVisualBehavior NONE = new NonAccessoryItemRegistry.ActorVisualBehavior(Set.of(), null, Map.of(), 0); + + public boolean substitutesMaleRole() { + return this.virtualActorTags.contains("gender.male") && "male".equals(this.animationModelGender); + } + } + + public record ItemClientModel(@Nullable Identifier itemModel, List layers) { + public static final NonAccessoryItemRegistry.ItemClientModel EMPTY = new NonAccessoryItemRegistry.ItemClientModel(null, List.of()); + + public boolean isEmpty() { + return this.itemModel == null && this.layers.isEmpty(); + } + } + + public record SkinOverlays( + List v, List a, List d + ) { + public static final NonAccessoryItemRegistry.SkinOverlays EMPTY = new NonAccessoryItemRegistry.SkinOverlays(List.of(), List.of(), List.of()); + + public boolean isEmpty() { + return this.v.isEmpty() && this.a.isEmpty() && this.d.isEmpty(); + } + + public List forSlot(String slotId) { + if (slotId == null) { + return List.of(); + } else { + String slot = slotId.toLowerCase(Locale.ROOT); + if (slot.equals("legs/v") || slot.startsWith("legs/v/")) { + return this.v; + } else if (slot.equals("legs/a") || slot.startsWith("legs/a/")) { + return this.a; + } else { + return !slot.equals("legs/d") && !slot.startsWith("legs/d/") ? List.of() : this.d; + } + } + } + } + + public enum TintMode { + NONE, + DYE, + STORED_LIQUID; + + static NonAccessoryItemRegistry.TintMode parse(@Nullable String raw) { + if (raw != null && !raw.isBlank()) { + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "dye" -> DYE; + case "stored_liquid" -> STORED_LIQUID; + default -> NONE; + }; + } else { + return NONE; + } + } + } + + public record VisualLayer(Identifier texture, NonAccessoryItemRegistry.TintMode tintMode) { + } +} diff --git a/src/main/java/com/nonid/data/NonAccessoryLiquidFilling.java b/src/main/java/com/nonid/data/NonAccessoryLiquidFilling.java new file mode 100644 index 0000000..ef955de --- /dev/null +++ b/src/main/java/com/nonid/data/NonAccessoryLiquidFilling.java @@ -0,0 +1,65 @@ +package com.nonid.data; + +import com.nonid.potion.NonPotions; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAccessoryLiquidFilling { + private NonAccessoryLiquidFilling() { + } + + public static boolean canFill(ItemStack accessory, ItemStack liquidBottle) { + return resolveFill(accessory, liquidBottle) != null; + } + + public static boolean fillInPlace(ItemStack accessory, ItemStack liquidBottle) { + NonAccessoryLiquidFilling.FillData fill = resolveFill(accessory, liquidBottle); + if (fill == null) { + return false; + } + + NonAccessoryStackData.setStoredLiquid(accessory, fill.entityTypeId(), fill.amountMl()); + return true; + } + + public static ItemStack createFilledCopy(ItemStack accessory, ItemStack liquidBottle) { + NonAccessoryLiquidFilling.FillData fill = resolveFill(accessory, liquidBottle); + if (fill == null) { + return ItemStack.EMPTY; + } + + ItemStack result = accessory.copyWithCount(1); + NonAccessoryStackData.setStoredLiquid(result, fill.entityTypeId(), fill.amountMl()); + return result; + } + + @Nullable + private static NonAccessoryLiquidFilling.FillData resolveFill(ItemStack accessory, ItemStack liquidBottle) { + if (accessory == null || accessory.isEmpty() || liquidBottle == null || liquidBottle.isEmpty()) { + return null; + } + + if (NonAccessoryStackData.getStoredLiquid(accessory) != null) { + return null; + } + + Identifier accessoryId = Registries.ITEM.getId(accessory.getItem()); + NonAccessoryItemRegistry.AccessoryItemDefinition definition = NonAccessoryItemRegistry.getDefinition(accessoryId); + if (definition == null) { + return null; + } + + int capacityMl = definition.actorVisualBehavior().storedLiquidCapacityMl(); + if (capacityMl <= 0) { + return null; + } + + Identifier entityTypeId = NonPotions.getLiquidBottleEntityTypeId(liquidBottle); + return entityTypeId == null ? null : new NonAccessoryLiquidFilling.FillData(entityTypeId, capacityMl); + } + + private record FillData(Identifier entityTypeId, int amountMl) { + } +} diff --git a/src/main/java/com/nonid/data/NonAccessoryStackData.java b/src/main/java/com/nonid/data/NonAccessoryStackData.java new file mode 100644 index 0000000..ca73d41 --- /dev/null +++ b/src/main/java/com/nonid/data/NonAccessoryStackData.java @@ -0,0 +1,69 @@ +package com.nonid.data; + +import com.nonid.NeedsOfNature; +import java.util.List; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.CustomModelDataComponent; +import net.minecraft.component.type.DyedColorComponent; +import net.minecraft.component.type.NbtComponent; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonAccessoryStackData { + private static final String LIQUID_ENTITY_KEY = "NonAccessoryLiquidEntity"; + private static final String LIQUID_AMOUNT_KEY = "NonAccessoryLiquidAmount"; + + private NonAccessoryStackData() { + } + + public static int resolveDyeRgb(ItemStack stack, int fallbackRgb) { + if (stack != null && !stack.isEmpty()) { + DyedColorComponent dyed = (DyedColorComponent)stack.get(DataComponentTypes.DYED_COLOR); + return (dyed == null ? fallbackRgb : dyed.rgb()) & 16777215; + } else { + return fallbackRgb & 16777215; + } + } + + @Nullable + public static NonAccessoryStackData.StoredLiquid getStoredLiquid(ItemStack stack) { + if (stack != null && !stack.isEmpty()) { + NbtComponent custom = (NbtComponent)stack.get(DataComponentTypes.CUSTOM_DATA); + if (custom != null && !custom.isEmpty()) { + NbtCompound nbt = custom.copyNbt(); + Identifier entityTypeId = Identifier.tryParse(nbt.getString("NonAccessoryLiquidEntity", "")); + int amount = nbt.getInt("NonAccessoryLiquidAmount", 0); + return entityTypeId != null && amount > 0 ? new NonAccessoryStackData.StoredLiquid(entityTypeId, amount) : null; + } else { + return null; + } + } else { + return null; + } + } + + public static void setStoredLiquid(ItemStack stack, @Nullable Identifier entityTypeId, int amountMl) { + if (stack != null && !stack.isEmpty()) { + int amount = Math.max(0, amountMl); + if (entityTypeId != null && amount != 0) { + NbtComponent.set(DataComponentTypes.CUSTOM_DATA, stack, nbt -> { + nbt.putString("NonAccessoryLiquidEntity", entityTypeId.toString()); + nbt.putInt("NonAccessoryLiquidAmount", amount); + }); + int argb = 0xFF000000 | NeedsOfNature.resolveBottleLiquidTintRgb(entityTypeId); + stack.set(DataComponentTypes.CUSTOM_MODEL_DATA, new CustomModelDataComponent(List.of(), List.of(true), List.of(), List.of(argb))); + } else { + NbtComponent.set(DataComponentTypes.CUSTOM_DATA, stack, nbt -> { + nbt.remove("NonAccessoryLiquidEntity"); + nbt.remove("NonAccessoryLiquidAmount"); + }); + stack.remove(DataComponentTypes.CUSTOM_MODEL_DATA); + } + } + } + + public record StoredLiquid(Identifier entityTypeId, int amountMl) { + } +} diff --git a/src/main/java/com/nonid/data/NonEntityProfiles.java b/src/main/java/com/nonid/data/NonEntityProfiles.java new file mode 100644 index 0000000..96a022c --- /dev/null +++ b/src/main/java/com/nonid/data/NonEntityProfiles.java @@ -0,0 +1,605 @@ +package com.nonid.data; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.entity.Entity; +import net.minecraft.registry.Registries; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonEntityProfiles { + private static final String ROOT_PATH = "non_entity_profiles"; + private static final String ENTRIES_KEY = "entries"; + private static final String ENTITY_KEY = "entity"; + private static final String PREGNANCY_CHANCE_KEY = "pregnancy_chance_percent"; + private static final String OFFSPRING_MIN_KEY = "offspring_min"; + private static final String OFFSPRING_MAX_KEY = "offspring_max"; + private static final String BIRTH_ENTITY_KEY = "birth_entity"; + private static final String BIRTH_MODE_KEY = "birth_mode"; + private static final String EGG_KEY = "egg"; + private static final String EGG_START_SIZE_KEY = "start_size"; + private static final String EGG_END_SIZE_KEY = "end_size"; + private static final String EGG_TEXTURE_KEY = "texture"; + private static final String EGG_HEALTH_KEY = "health"; + private static final String GENDER_SPAWN_KEY = "gender_spawn"; + private static final String MALE_CHANCE_KEY = "male_chance"; + private static final String FEMALE_CHANCE_KEY = "female_chance"; + private static final String BOTH_CHANCE_KEY = "both_chance"; + private static final String LOOK_AT_NEARBY_ANIMATIONS_KEY = "look_at_nearby_animations"; + private static final String MULTI_ACTOR_JOIN_CHANCE_KEY = "multi_actor_join_chance_percent"; + private static final String ENERGY_GAIN_MULTIPLIER_KEY = "energy_gain_multiplier"; + private static volatile Map PACK_PROFILES_BY_ENTITY = Map.of(); + private static volatile ConcurrentMap ENERGY_GAIN_MULTIPLIER_CACHE = new ConcurrentHashMap<>(); + + private NonEntityProfiles() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.SERVER_DATA).registerReloader(NonEntityProfiles.Reloader.RELOADER_ID, new NonEntityProfiles.Reloader()); + } + + public static NonEntityProfiles.ResolvedProfile resolve(@Nullable Entity entity) { + return entity == null ? fallbackProfile(null) : resolve(Registries.ENTITY_TYPE.getId(entity.getType())); + } + + public static NonEntityProfiles.ResolvedProfile resolve(@Nullable Identifier entityTypeId) { + NonEntityProfiles.ResolvedProfile profile = fallbackProfile(entityTypeId); + if (entityTypeId == null) { + return profile; + } + + NonConfig.EntityProfile hardcodedDefault = hardcodedDefaultProfile(entityTypeId); + if (hardcodedDefault != null) { + profile = apply(profile, hardcodedDefault, entityTypeId); + } + + NonConfig.EntityProfile packProfile = PACK_PROFILES_BY_ENTITY.get(entityTypeId); + if (packProfile != null) { + profile = apply(profile, packProfile, entityTypeId); + } + + NonConfig.EntityProfile configProfile = NeedsOfNature.getConfig().getEntityProfilesByEntity().get(entityTypeId.toString()); + if (configProfile != null) { + profile = apply(profile, configProfile, entityTypeId); + } + + return profile; + } + + public static float resolveEnergyGainMultiplier(@Nullable Entity entity) { + if (entity == null) { + return 1.0F; + } + + Identifier entityTypeId = Registries.ENTITY_TYPE.getId(entity.getType()); + return ENERGY_GAIN_MULTIPLIER_CACHE.computeIfAbsent(entityTypeId, id -> resolve(id).energyGainMultiplier()); + } + + public static void invalidateEnergyGainMultiplierCache() { + ENERGY_GAIN_MULTIPLIER_CACHE = new ConcurrentHashMap<>(); + } + + public static Map getPackProfilesByEntity() { + if (PACK_PROFILES_BY_ENTITY.isEmpty()) { + return Map.of(); + } + + Map out = new LinkedHashMap<>(); + + for (Entry entry : PACK_PROFILES_BY_ENTITY.entrySet()) { + out.put(entry.getKey().toString(), entry.getValue().sanitized()); + } + + return Map.copyOf(out); + } + + public static Map resolveEffectiveProfilesMap() { + Set ids = new LinkedHashSet<>(); + ids.addAll(hardcodedDefaultProfileIds()); + ids.addAll(PACK_PROFILES_BY_ENTITY.keySet()); + + for (String raw : NeedsOfNature.getConfig().getEntityProfilesByEntity().keySet()) { + Identifier id = Identifier.tryParse(raw); + if (id != null) { + ids.add(id); + } + } + + Map out = new LinkedHashMap<>(); + + for (Identifier id : ids) { + out.put(id.toString(), resolve(id).toConfigProfile()); + } + + return Map.copyOf(out); + } + + public static Map resolveDefaultProfilesMap() { + Set ids = new LinkedHashSet<>(); + ids.addAll(hardcodedDefaultProfileIds()); + ids.addAll(PACK_PROFILES_BY_ENTITY.keySet()); + Map out = new LinkedHashMap<>(); + + for (Identifier id : ids) { + NonEntityProfiles.ResolvedProfile profile = fallbackProfile(id); + NonConfig.EntityProfile hardcodedDefault = hardcodedDefaultProfile(id); + if (hardcodedDefault != null) { + profile = apply(profile, hardcodedDefault, id); + } + + NonConfig.EntityProfile packProfile = PACK_PROFILES_BY_ENTITY.get(id); + if (packProfile != null) { + profile = apply(profile, packProfile, id); + } + + out.put(id.toString(), profile.toConfigProfile()); + } + + return Map.copyOf(out); + } + + public static NonEntityProfiles.ResolvedProfile resolveDefaultProfile(@Nullable Identifier entityTypeId) { + NonEntityProfiles.ResolvedProfile profile = fallbackProfile(entityTypeId); + if (entityTypeId == null) { + return profile; + } + + NonConfig.EntityProfile hardcodedDefault = hardcodedDefaultProfile(entityTypeId); + if (hardcodedDefault != null) { + profile = apply(profile, hardcodedDefault, entityTypeId); + } + + NonConfig.EntityProfile packProfile = PACK_PROFILES_BY_ENTITY.get(entityTypeId); + if (packProfile != null) { + profile = apply(profile, packProfile, entityTypeId); + } + + return profile; + } + + public static Map resolveEffectiveGenderSpawnMap() { + Map out = new LinkedHashMap<>(); + + for (Entry entry : resolveEffectiveProfilesMap().entrySet()) { + NonConfig.GenderSpawnChances chances = entry.getValue().genderSpawn(); + if (chances != null) { + out.put(entry.getKey(), chances); + } + } + + return Map.copyOf(out); + } + + private static NonEntityProfiles.ResolvedProfile fallbackProfile(@Nullable Identifier entityTypeId) { + String entity = entityTypeId == null ? "minecraft:pig" : entityTypeId.toString(); + return new NonEntityProfiles.ResolvedProfile( + NeedsOfNature.getConfig().getPregnancyChancePercent(), + 1, + 1, + Identifier.tryParse(entity) == null ? Identifier.of("minecraft", "pig") : Identifier.tryParse(entity), + "direct", + NeedsOfNature.getConfig().getGlobalGenderSpawnChances(), + NonConfig.EggProfile.defaults(), + true, + null, + 1.0F + ); + } + + private static NonEntityProfiles.ResolvedProfile apply(NonEntityProfiles.ResolvedProfile base, NonConfig.EntityProfile patch, Identifier sourceEntityId) { + if (patch == null) { + return base; + } + + NonConfig.EntityProfile safe = patch.sanitized(); + int chance = safe.pregnancyChancePercent() == null ? base.pregnancyChancePercent() : safe.pregnancyChancePercent(); + int min = safe.offspringMin() == null ? base.offspringMin() : safe.offspringMin(); + int max = safe.offspringMax() == null ? base.offspringMax() : safe.offspringMax(); + if (max < min) { + max = min; + } + + Identifier birthEntity = base.birthEntityTypeId(); + String rawBirthEntity = safe.birthEntity(); + if (rawBirthEntity != null) { + Identifier parsed = Identifier.tryParse(rawBirthEntity); + if (parsed != null && Registries.ENTITY_TYPE.containsId(parsed)) { + birthEntity = parsed; + } else { + NeedsOfNature.logSetupWarning("[NoN setup] Ignoring invalid birth_entity '{}' for entity profile {}.", rawBirthEntity, sourceEntityId); + } + } + + String birthMode = safe.birthMode() == null ? base.birthMode() : safe.birthMode(); + NonConfig.GenderSpawnChances genderSpawn = safe.genderSpawn() == null ? base.genderSpawn() : safe.genderSpawn(); + NonConfig.EggProfile eggProfile = safe.egg() == null ? base.eggProfile() : safe.egg().resolvedOver(base.eggProfile()); + boolean lookAtNearbyAnimations = safe.lookAtNearbyAnimations() == null ? base.lookAtNearbyAnimations() : safe.lookAtNearbyAnimations(); + Integer multiActorJoinChancePercent = safe.multiActorJoinChancePercent() == null + ? base.multiActorJoinChancePercent() + : safe.multiActorJoinChancePercent(); + float energyGainMultiplier = safe.energyGainMultiplier() == null ? base.energyGainMultiplier() : safe.energyGainMultiplier(); + return new NonEntityProfiles.ResolvedProfile( + chance, min, max, birthEntity, birthMode, genderSpawn, eggProfile, lookAtNearbyAnimations, multiActorJoinChancePercent, energyGainMultiplier + ); + } + + @Nullable + private static NonConfig.EntityProfile hardcodedDefaultProfile(Identifier entityTypeId) { + return null; + } + + private static Set hardcodedDefaultProfileIds() { + return Set.of(); + } + + private static void reload(ResourceManager resourceManager) { + Map resources = resourceManager.findResources("non_entity_profiles", id -> id.getPath().endsWith(".json")); + List> ordered = new ArrayList<>(resources.entrySet()); + ordered.sort(Comparator.comparing(entryx -> ((Identifier)entryx.getKey()).toString())); + Map profiles = new LinkedHashMap<>(); + + for (Entry entry : ordered) { + Identifier fileId = entry.getKey(); + + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + JsonArray entries = asArray(root.get("entries")); + if (entries != null && !entries.isEmpty()) { + parseEntries(fileId, entries, profiles); + } + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to read entity profile file {}", fileId, e); + } + } + + PACK_PROFILES_BY_ENTITY = Map.copyOf(profiles); + invalidateEnergyGainMultiplierCache(); + } + + private static void parseEntries(Identifier fileId, JsonArray entries, Map profiles) { + for (int i = 0; i < entries.size(); i++) { + JsonObject obj = asObject(entries.get(i)); + if (obj == null) { + warnMalformed(fileId, i, "Entry is not an object."); + } else { + Identifier entityId = parseEntityId(obj.get("entity")); + if (entityId == null) { + warnMalformed(fileId, i, "Missing or invalid 'entity'."); + } else { + NonConfig.EntityProfile profile = parseProfile(fileId, i, obj, entityId); + if (profile != null && !profile.isEmpty()) { + profiles.put(entityId, profile); + } else { + warnMalformed(fileId, i, "Entry has no valid profile fields."); + } + } + } + } + } + + @Nullable + private static NonConfig.EntityProfile parseProfile(Identifier fileId, int index, JsonObject obj, Identifier entityId) { + Integer chance = parseOptionalInt(obj.get("pregnancy_chance_percent")); + Integer offspringMin = parseOptionalInt(obj.get("offspring_min")); + Integer offspringMax = parseOptionalInt(obj.get("offspring_max")); + String birthEntity = null; + if (obj.has("birth_entity")) { + Identifier parsed = parseEntityId(obj.get("birth_entity")); + if (parsed != null && Registries.ENTITY_TYPE.containsId(parsed)) { + birthEntity = parsed.toString(); + } else { + warnMalformed(fileId, index, "Invalid 'birth_entity'."); + } + } + + String birthMode = null; + if (obj.has("birth_mode")) { + birthMode = parseOptionalString(obj.get("birth_mode")); + birthMode = NonConfig.normalizeBirthMode(birthMode); + if (birthMode == null) { + warnMalformed(fileId, index, "Invalid 'birth_mode'. Use 'egg' or 'direct'."); + } + } + + NonConfig.GenderSpawnChances genderSpawn = null; + JsonObject genderObj = asObject(obj.get("gender_spawn")); + if (genderObj != null) { + genderSpawn = parseGenderSpawn(fileId, index, genderObj); + } + + NonConfig.EggProfile eggProfile = null; + JsonObject eggObj = asObject(obj.get("egg")); + if (eggObj != null) { + eggProfile = parseEggProfile(fileId, index, eggObj); + } + + Boolean lookAtNearbyAnimations = parseOptionalBoolean(obj.get("look_at_nearby_animations")); + if (obj.has("look_at_nearby_animations") && lookAtNearbyAnimations == null) { + warnMalformed(fileId, index, "'look_at_nearby_animations' must be a boolean."); + } + + Integer multiActorJoinChancePercent = parseOptionalInt(obj.get("multi_actor_join_chance_percent")); + if (obj.has("multi_actor_join_chance_percent") && multiActorJoinChancePercent == null) { + warnMalformed(fileId, index, "'multi_actor_join_chance_percent' must be an integer from 0 to 100."); + } else if (multiActorJoinChancePercent != null && (multiActorJoinChancePercent < 0 || multiActorJoinChancePercent > 100)) { + warnMalformed(fileId, index, "'multi_actor_join_chance_percent' must be 0-100."); + multiActorJoinChancePercent = null; + } + + Float energyGainMultiplier = parseOptionalFloat(obj.get("energy_gain_multiplier")); + if (obj.has("energy_gain_multiplier") && energyGainMultiplier == null) { + warnMalformed(fileId, index, "'energy_gain_multiplier' must be a number from 0.1 to 10.0."); + } else if (energyGainMultiplier != null && (energyGainMultiplier < 0.1F || energyGainMultiplier > 10.0F)) { + warnMalformed(fileId, index, "'energy_gain_multiplier' must be 0.1-10.0."); + energyGainMultiplier = null; + } + + if (chance != null && (chance < 0 || chance > 100)) { + warnMalformed(fileId, index, "'pregnancy_chance_percent' must be 0-100."); + chance = null; + } + + if (offspringMin != null && (offspringMin < 1 || offspringMin > 16)) { + warnMalformed(fileId, index, "'offspring_min' is out of range."); + offspringMin = null; + } + + if (offspringMax != null && (offspringMax < 1 || offspringMax > 16)) { + warnMalformed(fileId, index, "'offspring_max' is out of range."); + offspringMax = null; + } + + if (offspringMin != null && offspringMax != null && offspringMax < offspringMin) { + warnMalformed(fileId, index, "'offspring_max' must be at least 'offspring_min'."); + offspringMax = offspringMin; + } + + return new NonConfig.EntityProfile( + chance, + offspringMin, + offspringMax, + birthEntity, + birthMode, + genderSpawn, + eggProfile, + lookAtNearbyAnimations, + multiActorJoinChancePercent, + energyGainMultiplier + ) + .sanitized(); + } + + @Nullable + private static NonConfig.EggProfile parseEggProfile(Identifier fileId, int index, JsonObject obj) { + Float startSize = parseOptionalFloat(obj.get("start_size")); + Float endSize = parseOptionalFloat(obj.get("end_size")); + Float health = parseOptionalFloat(obj.get("health")); + String texture = null; + if (obj.has("texture")) { + texture = parseOptionalString(obj.get("texture")); + if (texture != null && NonConfig.normalizeTextureIdString(texture) == null) { + warnMalformed(fileId, index, "Invalid egg 'texture'."); + texture = null; + } + } + + if (obj.has("start_size") && startSize == null) { + warnMalformed(fileId, index, "Egg 'start_size' must be a number."); + } + + if (obj.has("end_size") && endSize == null) { + warnMalformed(fileId, index, "Egg 'end_size' must be a number."); + } + + if (obj.has("health") && health == null) { + warnMalformed(fileId, index, "Egg 'health' must be a number."); + } + + if (startSize != null && (startSize < 0.05F || startSize > 4.0F)) { + warnMalformed(fileId, index, "Egg 'start_size' is out of range."); + startSize = null; + } + + if (endSize != null && (endSize < 0.05F || endSize > 4.0F)) { + warnMalformed(fileId, index, "Egg 'end_size' is out of range."); + endSize = null; + } + + if (health != null && (health < 0.5F || health > 100.0F)) { + warnMalformed(fileId, index, "Egg 'health' is out of range."); + health = null; + } + + NonConfig.EggProfile profile = new NonConfig.EggProfile(startSize, endSize, texture, health).sanitized(); + return profile.isEmpty() ? null : profile; + } + + @Nullable + private static NonConfig.GenderSpawnChances parseGenderSpawn(Identifier fileId, int index, JsonObject obj) { + Integer male = parseOptionalInt(obj.get("male_chance")); + Integer female = parseOptionalInt(obj.get("female_chance")); + Integer both = parseOptionalInt(obj.get("both_chance")); + if (male != null && female != null && both != null) { + NonConfig.GenderSpawnChances chances = NonConfig.sanitizeGenderSpawnChances(new NonConfig.GenderSpawnChances(male, female, both)); + if (chances == null) { + warnMalformed(fileId, index, "Gender spawn percentages must be 0-100 and total exactly 100."); + } + + return chances; + } else { + warnMalformed(fileId, index, "Gender spawn needs male_chance, female_chance, and both_chance."); + return null; + } + } + + @Nullable + private static Identifier parseEntityId(@Nullable JsonElement element) { + String raw = parseOptionalString(element); + return raw == null ? null : Identifier.tryParse(raw); + } + + @Nullable + private static String parseOptionalString(@Nullable JsonElement element) { + if (element == null || !element.isJsonPrimitive()) { + return null; + } + + if (!element.getAsJsonPrimitive().isString()) { + return null; + } + + String raw = element.getAsString(); + return raw != null && !raw.isBlank() ? raw.trim() : null; + } + + @Nullable + private static Integer parseOptionalInt(@Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + if (!Double.isFinite(value) || value != Math.rint(value)) { + return null; + } else { + return !(value < -2.1474836E9F) && !(value > 2.147483647E9) ? (int)value : null; + } + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static Float parseOptionalFloat(@Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (!primitive.isNumber()) { + return null; + } else { + double value = primitive.getAsDouble(); + if (!Double.isFinite(value)) { + return null; + } else { + return !(value < -Float.MAX_VALUE) && !(value > Float.MAX_VALUE) ? (float)value : null; + } + } + } else { + return null; + } + } + + @Nullable + private static Boolean parseOptionalBoolean(@Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + return primitive.isBoolean() ? primitive.getAsBoolean() : null; + } else { + return null; + } + } + + @Nullable + private static JsonObject asObject(@Nullable JsonElement element) { + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } + + @Nullable + private static JsonArray asArray(@Nullable JsonElement element) { + return element != null && element.isJsonArray() ? element.getAsJsonArray() : null; + } + + private static void warnMalformed(Identifier fileId, int index, String reason) { + NeedsOfNature.logSetupWarning("[NoN setup] Ignoring malformed entity profile entry in {} at index {}: {}", fileId, index, reason); + } + + public static final class Reloader implements SynchronousResourceReloader { + static final Identifier RELOADER_ID = Identifier.of("needsofnature", "entity_profiles"); + + public void reload(ResourceManager manager) { + NonEntityProfiles.reload(manager); + } + } + + public record ResolvedProfile( + int pregnancyChancePercent, + int offspringMin, + int offspringMax, + Identifier birthEntityTypeId, + String birthMode, + NonConfig.GenderSpawnChances genderSpawn, + NonConfig.EggProfile eggProfile, + boolean lookAtNearbyAnimations, + @Nullable Integer multiActorJoinChancePercent, + float energyGainMultiplier + ) { + public ResolvedProfile { + pregnancyChancePercent = Math.max(0, Math.min(100, pregnancyChancePercent)); + offspringMin = Math.max(1, Math.min(16, offspringMin)); + offspringMax = Math.max(offspringMin, Math.min(16, offspringMax)); + if (birthEntityTypeId == null) { + birthEntityTypeId = Identifier.of("minecraft", "pig"); + } + + birthMode = NonConfig.normalizeBirthMode(birthMode); + if (birthMode == null) { + birthMode = "direct"; + } + + genderSpawn = NonConfig.validOrDefault(genderSpawn); + eggProfile = eggProfile == null ? NonConfig.EggProfile.defaults() : eggProfile.resolvedOver(NonConfig.EggProfile.defaults()); + if (multiActorJoinChancePercent != null) { + multiActorJoinChancePercent = Math.max(0, Math.min(100, multiActorJoinChancePercent)); + } + + if (!Float.isFinite(energyGainMultiplier)) { + energyGainMultiplier = 1.0F; + } + + energyGainMultiplier = Math.max(0.1F, Math.min(10.0F, energyGainMultiplier)); + } + + public NonConfig.OffspringCountRange offspringRange() { + return new NonConfig.OffspringCountRange(this.offspringMin, this.offspringMax); + } + + public NonConfig.EntityProfile toConfigProfile() { + return NonConfig.EntityProfile.full( + this.pregnancyChancePercent, + this.offspringMin, + this.offspringMax, + this.birthEntityTypeId.toString(), + this.birthMode, + this.genderSpawn, + this.eggProfile, + this.lookAtNearbyAnimations, + this.multiActorJoinChancePercent, + this.energyGainMultiplier + ); + } + } +} diff --git a/src/main/java/com/nonid/data/NonLiquidGainOverrides.java b/src/main/java/com/nonid/data/NonLiquidGainOverrides.java new file mode 100644 index 0000000..da5c477 --- /dev/null +++ b/src/main/java/com/nonid/data/NonLiquidGainOverrides.java @@ -0,0 +1,218 @@ +package com.nonid.data; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.nonid.NeedsOfNature; +import com.nonid.client.NonLiquidColors; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonLiquidGainOverrides { + private static final String ROOT_PATH = "non_liquid_gains"; + private static final String ENTRIES_KEY = "entries"; + private static final String ENTITY_KEY = "entity"; + private static final String GAIN_ML_KEY = "gain_ml"; + private static final String COLOR_KEY = "color"; + private static final String MIXED_COLOR_KEY = "mixed_color"; + private static volatile Map GAIN_BY_ENTITY = Map.of(); + private static volatile Map COLOR_HEX_BY_ENTITY = Map.of(); + @Nullable + private static volatile String MIXED_COLOR_HEX = null; + + private NonLiquidGainOverrides() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.SERVER_DATA).registerReloader(NonLiquidGainOverrides.Reloader.RELOADER_ID, new NonLiquidGainOverrides.Reloader()); + } + + @Nullable + public static Integer getGain(@Nullable Identifier entityTypeId) { + return entityTypeId == null ? null : GAIN_BY_ENTITY.get(entityTypeId); + } + + @Nullable + public static Integer getColorRgb(@Nullable Identifier entityTypeId) { + if (entityTypeId == null) { + return null; + } + + String hex = COLOR_HEX_BY_ENTITY.get(entityTypeId); + return NonLiquidColors.parseHexColor(hex); + } + + @Nullable + public static Integer getMixedColorRgb() { + return NonLiquidColors.parseHexColor(MIXED_COLOR_HEX); + } + + @Nullable + public static String getMixedColorHex() { + return MIXED_COLOR_HEX; + } + + public static Map getGainByEntity() { + if (GAIN_BY_ENTITY.isEmpty()) { + return Map.of(); + } + + Map out = new LinkedHashMap<>(); + + for (Entry entry : GAIN_BY_ENTITY.entrySet()) { + out.put(entry.getKey().toString(), entry.getValue()); + } + + return Map.copyOf(out); + } + + public static Map getColorByEntity() { + if (COLOR_HEX_BY_ENTITY.isEmpty()) { + return Map.of(); + } + + Map out = new LinkedHashMap<>(); + + for (Entry entry : COLOR_HEX_BY_ENTITY.entrySet()) { + out.put(entry.getKey().toString(), entry.getValue()); + } + + return Map.copyOf(out); + } + + private static void reload(ResourceManager resourceManager) { + Map resources = resourceManager.findResources("non_liquid_gains", id -> id.getPath().endsWith(".json")); + Map gains = new LinkedHashMap<>(); + Map colors = new LinkedHashMap<>(); + String mixedColorHex = null; + List> ordered = new ArrayList<>(resources.entrySet()); + ordered.sort(Comparator.comparing(entryx -> ((Identifier)entryx.getKey()).toString())); + + for (Entry entry : ordered) { + Identifier fileId = entry.getKey(); + + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + String mixedColor = parseOptionalColor(root.get("mixed_color")); + if (mixedColor != null) { + mixedColorHex = mixedColor; + } + + JsonArray entries = asArray(root.get("entries")); + if (entries != null && !entries.isEmpty()) { + parseEntries(fileId, entries, gains, colors); + } + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to read liquid gain override file {}", fileId, e); + } + } + + GAIN_BY_ENTITY = Map.copyOf(gains); + COLOR_HEX_BY_ENTITY = Map.copyOf(colors); + MIXED_COLOR_HEX = NonLiquidColors.normalizeHexColor(mixedColorHex); + } + + private static void parseEntries(Identifier fileId, JsonArray entries, Map gains, Map colors) { + for (int i = 0; i < entries.size(); i++) { + JsonObject obj = asObject(entries.get(i)); + if (obj == null) { + warnMalformed(fileId, i, "Entry is not an object."); + } else { + Identifier entityId = parseEntityId(obj.get("entity")); + if (entityId == null) { + warnMalformed(fileId, i, "Missing or invalid 'entity'."); + } else { + Integer gain = parseOptionalInt(obj.get("gain_ml")); + if (gain != null) { + gains.put(entityId, Math.max(0, Math.min(1000, gain))); + } + + String normalizedHex = parseOptionalColor(obj.get("color")); + if (normalizedHex != null) { + colors.put(entityId, normalizedHex); + } + } + } + } + } + + @Nullable + private static Identifier parseEntityId(@Nullable JsonElement element) { + if (element == null || !element.isJsonPrimitive()) { + return null; + } + + if (!element.getAsJsonPrimitive().isString()) { + return null; + } + + String raw = element.getAsString(); + return raw != null && !raw.isBlank() ? Identifier.tryParse(raw.trim()) : null; + } + + @Nullable + private static Integer parseOptionalInt(@Nullable JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + if (!Double.isFinite(value) || value != Math.rint(value)) { + return null; + } else { + return !(value < -2.1474836E9F) && !(value > 2.147483647E9) ? (int)value : null; + } + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static String parseOptionalColor(@Nullable JsonElement element) { + if (element == null || !element.isJsonPrimitive()) { + return null; + } else { + return !element.getAsJsonPrimitive().isString() ? null : NonLiquidColors.normalizeHexColor(element.getAsString()); + } + } + + @Nullable + private static JsonObject asObject(@Nullable JsonElement element) { + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } + + @Nullable + private static JsonArray asArray(@Nullable JsonElement element) { + return element != null && element.isJsonArray() ? element.getAsJsonArray() : null; + } + + private static void warnMalformed(Identifier fileId, int index, String reason) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring malformed liquid gain entry in {} at index {}: {}", fileId, index, reason); + } + + public static final class Reloader implements SynchronousResourceReloader { + static final Identifier RELOADER_ID = Identifier.of("needsofnature", "liquid_gain_overrides"); + + public void reload(ResourceManager manager) { + NonLiquidGainOverrides.reload(manager); + } + } +} diff --git a/src/main/java/com/nonid/data/NonLiquidRoles.java b/src/main/java/com/nonid/data/NonLiquidRoles.java new file mode 100644 index 0000000..8a04ef3 --- /dev/null +++ b/src/main/java/com/nonid/data/NonLiquidRoles.java @@ -0,0 +1,19 @@ +package com.nonid.data; + +import java.util.Map; +import java.util.Set; + +public final class NonLiquidRoles { + private NonLiquidRoles() { + } + + public enum InjectorRole { + NONE, + V, + A, + M; + } + + public record LiquidRoles(Set injectorKeys, Set receiverKeys, Map injectorRoles) { + } +} diff --git a/src/main/java/com/nonid/data/NonManualPeakDefinitions.java b/src/main/java/com/nonid/data/NonManualPeakDefinitions.java new file mode 100644 index 0000000..95ce72d --- /dev/null +++ b/src/main/java/com/nonid/data/NonManualPeakDefinitions.java @@ -0,0 +1,158 @@ +package com.nonid.data; + +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.WeightedPicker; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonManualPeakDefinitions { + private static final String MANUAL_PEAK_TAG = "manualpeak"; + + private NonManualPeakDefinitions() { + } + + @Nullable + public static NonManualPeakDefinitions.Selection select(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + List actors = List.of(player); + List matching = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : AnimationDefinitions.getLoadedDefinitionsSnapshot()) { + if (definition != null && definition.matches(actors, Set.of("manualpeak"))) { + matching.add(definition); + } + } + + if (matching.isEmpty()) { + return null; + } + + NonManualPeakDefinitions.HeldItemMatch heldItemMatch = findHeldItemMatch(world, matching, player); + if (heldItemMatch != null) { + boolean useHeldItemProp = heldItemMatch.rule().propFromHeldItem(); + return buildSelection( + world, + actors, + heldItemMatch.definition(), + useHeldItemProp ? heldItemMatch.itemId() : null, + useHeldItemProp ? heldItemMatch.rule().heldItemPropSlot() : null, + heldItemMatch.itemId() + ); + } + + List fallback = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : matching) { + AnimationGameplayMetadata.ManualPeakRule rule = AnimationGameplayMetadata.manualPeakRule(definition.id()); + if (rule == null || rule.heldItems().isEmpty()) { + fallback.add(definition); + } + } + + AnimationDefinitions.Definition picked = pickWeighted(world, fallback); + return picked == null ? null : buildSelection(world, actors, picked, null, null, null); + } else { + return null; + } + } + + @Nullable + private static NonManualPeakDefinitions.HeldItemMatch findHeldItemMatch( + ServerWorld world, List definitions, ServerPlayerEntity player + ) { + List matches = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : definitions) { + AnimationGameplayMetadata.ManualPeakRule rule = AnimationGameplayMetadata.manualPeakRule(definition.id()); + if (rule != null && !rule.heldItems().isEmpty()) { + Identifier itemId = firstMatchingHeldItem(player, rule.heldItems()); + if (itemId != null) { + matches.add(new NonManualPeakDefinitions.HeldItemMatch(definition, rule, itemId)); + } + } + } + + if (matches.isEmpty()) { + return null; + } + + int highestPriority = matches.stream().mapToInt(match -> match.rule().priority()).max().orElse(Integer.MIN_VALUE); + List topPriority = matches.stream() + .filter(match -> match.rule().priority() == highestPriority) + .sorted(Comparator.comparing(match -> match.definition().id().toString())) + .toList(); + return WeightedPicker.pick(topPriority, match -> match.definition().weight(), world.getRandom().nextDouble()); + } + + @Nullable + private static Identifier firstMatchingHeldItem(ServerPlayerEntity player, Set accepted) { + Identifier main = itemId(player.getMainHandStack()); + if (main != null && accepted.contains(main)) { + return main; + } + + Identifier offhand = itemId(player.getOffHandStack()); + return offhand != null && accepted.contains(offhand) ? offhand : null; + } + + @Nullable + private static Identifier itemId(ItemStack stack) { + return stack != null && !stack.isEmpty() ? Registries.ITEM.getId(stack.getItem()) : null; + } + + @Nullable + private static NonManualPeakDefinitions.Selection buildSelection( + ServerWorld world, + List actors, + AnimationDefinitions.Definition definition, + @Nullable Identifier heldItemPropItemId, + @Nullable AnimationStageInfo.PropSlot heldItemPropSlot, + @Nullable Identifier matchedHeldItemId + ) { + List actorKeys = AnimationDefinitions.resolveActorKeys(definition, actors, world.getRandom()); + return actorKeys != null && actorKeys.size() == actors.size() + ? new NonManualPeakDefinitions.Selection(definition, List.copyOf(actorKeys), heldItemPropItemId, heldItemPropSlot, matchedHeldItemId) + : null; + } + + @Nullable + private static AnimationDefinitions.Definition pickWeighted(ServerWorld world, List definitions) { + if (world != null && definitions != null && !definitions.isEmpty()) { + definitions.sort(Comparator.comparing(definition -> definition.id().toString())); + return WeightedPicker.pick(definitions, AnimationDefinitions.Definition::weight, world.getRandom().nextDouble()); + } else { + return null; + } + } + + private record HeldItemMatch(AnimationDefinitions.Definition definition, AnimationGameplayMetadata.ManualPeakRule rule, Identifier itemId) { + } + + public record Selection( + AnimationDefinitions.Definition definition, + List actorKeys, + @Nullable Identifier heldItemPropItemId, + @Nullable AnimationStageInfo.PropSlot heldItemPropSlot, + @Nullable Identifier matchedHeldItemId + ) { + public List stages() { + if (this.heldItemPropItemId != null && this.heldItemPropSlot != null && !this.actorKeys.isEmpty()) { + String actorKey = this.actorKeys.getFirst(); + return this.definition.stages().stream().map(stage -> stage.withActorProp(actorKey, this.heldItemPropSlot, this.heldItemPropItemId)).toList(); + } else { + return this.definition.stages(); + } + } + } +} diff --git a/src/main/java/com/nonid/data/NonPeakStages.java b/src/main/java/com/nonid/data/NonPeakStages.java new file mode 100644 index 0000000..9e0a03a --- /dev/null +++ b/src/main/java/com/nonid/data/NonPeakStages.java @@ -0,0 +1,64 @@ +package com.nonid.data; + +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.util.AnimationIds; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.util.Identifier; + +public final class NonPeakStages { + private static volatile Map syncedPeakStages = Map.of(); + + private NonPeakStages() { + } + + public static Integer getPeakStage(Identifier animationId) { + if (animationId == null) { + return null; + } + + Identifier base = baseAnimationId(animationId); + Integer synced = syncedPeakStages.get(base); + return synced != null ? synced : AnimationGameplayMetadata.peakStage(base); + } + + public static Map snapshotPeakStagesForSync() { + Map out = new LinkedHashMap<>(); + AnimationGameplayMetadata.peakStages().forEach((id, stage) -> { + if (id != null && stage != null && stage > 0) { + out.put(id.toString(), stage); + } + }); + return out; + } + + public static void applySyncedPeakStages(Map synced) { + if (synced != null && !synced.isEmpty()) { + Map parsed = new HashMap<>(); + synced.forEach((rawId, stage) -> { + if (rawId != null && stage != null && stage > 0) { + Identifier id = Identifier.tryParse(rawId); + if (id != null) { + parsed.put(baseAnimationId(id), stage); + } + } + }); + syncedPeakStages = Map.copyOf(parsed); + } else { + syncedPeakStages = Map.of(); + } + } + + public static void clearSyncedPeakStages() { + syncedPeakStages = Map.of(); + } + + public static Identifier baseAnimationId(Identifier id) { + return AnimationIds.baseId(id); + } + + public static Integer stageNumberFromId(Identifier stageId) { + return stageId == null ? null : AnimationIds.stageNumberOrDefault(stageId, 1); + } +} diff --git a/src/main/java/com/nonid/effect/EnergizedStatusEffect.java b/src/main/java/com/nonid/effect/EnergizedStatusEffect.java new file mode 100644 index 0000000..9c390c0 --- /dev/null +++ b/src/main/java/com/nonid/effect/EnergizedStatusEffect.java @@ -0,0 +1,10 @@ +package com.nonid.effect; + +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; + +public class EnergizedStatusEffect extends StatusEffect { + public EnergizedStatusEffect() { + super(StatusEffectCategory.HARMFUL, 15906891); + } +} diff --git a/src/main/java/com/nonid/effect/EnergyReliefStatusEffect.java b/src/main/java/com/nonid/effect/EnergyReliefStatusEffect.java new file mode 100644 index 0000000..13fdbd6 --- /dev/null +++ b/src/main/java/com/nonid/effect/EnergyReliefStatusEffect.java @@ -0,0 +1,86 @@ +package com.nonid.effect; + +import com.nonid.EnergyHolder; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.server.world.ServerWorld; + +public class EnergyReliefStatusEffect extends StatusEffect { + private static final double TOTAL_RATIO = 0.5; + private final Map applications = new HashMap<>(); + + public EnergyReliefStatusEffect() { + super(StatusEffectCategory.BENEFICIAL, 5219327); + } + + public boolean canApplyUpdateEffect(int duration, int amplifier) { + return true; + } + + public boolean applyUpdateEffect(ServerWorld world, LivingEntity entity, int amplifier) { + if (!(entity instanceof EnergyHolder holder)) { + return true; + } else { + StatusEffectInstance instance = null; + + for (StatusEffectInstance effect : entity.getStatusEffects()) { + if (effect.getEffectType().value() == this) { + instance = effect; + break; + } + } + + if (instance == null) { + return true; + } + + UUID id = entity.getUuid(); + int duration = instance.getDuration(); + EnergyReliefStatusEffect.ApplicationState state = this.applications.get(id); + if (state == null || state.instance != instance) { + state = new EnergyReliefStatusEffect.ApplicationState(instance, duration); + this.applications.put(id, state); + } else if (duration > state.initialDuration) { + state.initialDuration = duration; + } + + int max = holder.getMaxEnergy(); + if (max > 0) { + double total = max * 0.5 * (amplifier + 1); + double perTick = total / Math.max(1, state.initialDuration); + double accum = state.remainder + perTick; + int sub = (int)Math.floor(accum); + state.remainder = accum - sub; + if (sub > 0) { + holder.setEnergy(Math.max(0, holder.getEnergy() - sub)); + } + } + + if (duration <= 1) { + this.applications.remove(id); + } + + return true; + } + } + + public void clearRuntimeState() { + this.applications.clear(); + } + + private static final class ApplicationState { + private final StatusEffectInstance instance; + private int initialDuration; + private double remainder; + + private ApplicationState(StatusEffectInstance instance, int initialDuration) { + this.instance = instance; + this.initialDuration = initialDuration; + } + } +} diff --git a/src/main/java/com/nonid/effect/EnergyStatusEffect.java b/src/main/java/com/nonid/effect/EnergyStatusEffect.java new file mode 100644 index 0000000..43ae417 --- /dev/null +++ b/src/main/java/com/nonid/effect/EnergyStatusEffect.java @@ -0,0 +1,86 @@ +package com.nonid.effect; + +import com.nonid.EnergyHolder; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.server.world.ServerWorld; + +public class EnergyStatusEffect extends StatusEffect { + private static final double TOTAL_RATIO = 0.5; + private final Map applications = new HashMap<>(); + + public EnergyStatusEffect() { + super(StatusEffectCategory.HARMFUL, 10494192); + } + + public boolean canApplyUpdateEffect(int duration, int amplifier) { + return true; + } + + public boolean applyUpdateEffect(ServerWorld world, LivingEntity entity, int amplifier) { + if (!(entity instanceof EnergyHolder holder)) { + return true; + } else { + StatusEffectInstance instance = null; + + for (StatusEffectInstance effect : entity.getStatusEffects()) { + if (effect.getEffectType().value() == this) { + instance = effect; + break; + } + } + + if (instance == null) { + return true; + } + + UUID id = entity.getUuid(); + int duration = instance.getDuration(); + EnergyStatusEffect.ApplicationState state = this.applications.get(id); + if (state == null || state.instance != instance) { + state = new EnergyStatusEffect.ApplicationState(instance, duration); + this.applications.put(id, state); + } else if (duration > state.initialDuration) { + state.initialDuration = duration; + } + + int max = holder.getMaxEnergy(); + if (max > 0) { + double total = max * 0.5 * (amplifier + 1); + double perTick = total / Math.max(1, state.initialDuration); + double accum = state.remainder + perTick; + int add = (int)Math.floor(accum); + state.remainder = accum - add; + if (add > 0) { + holder.setEnergy(Math.min(max, holder.getEnergy() + add)); + } + } + + if (duration <= 1) { + this.applications.remove(id); + } + + return true; + } + } + + public void clearRuntimeState() { + this.applications.clear(); + } + + private static final class ApplicationState { + private final StatusEffectInstance instance; + private int initialDuration; + private double remainder; + + private ApplicationState(StatusEffectInstance instance, int initialDuration) { + this.instance = instance; + this.initialDuration = initialDuration; + } + } +} diff --git a/src/main/java/com/nonid/effect/FertilityIncreaserStatusEffect.java b/src/main/java/com/nonid/effect/FertilityIncreaserStatusEffect.java new file mode 100644 index 0000000..6662c1c --- /dev/null +++ b/src/main/java/com/nonid/effect/FertilityIncreaserStatusEffect.java @@ -0,0 +1,10 @@ +package com.nonid.effect; + +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; + +public final class FertilityIncreaserStatusEffect extends StatusEffect { + public FertilityIncreaserStatusEffect() { + super(StatusEffectCategory.BENEFICIAL, 16044143); + } +} diff --git a/src/main/java/com/nonid/effect/FilledStatusEffect.java b/src/main/java/com/nonid/effect/FilledStatusEffect.java new file mode 100644 index 0000000..3a82b1d --- /dev/null +++ b/src/main/java/com/nonid/effect/FilledStatusEffect.java @@ -0,0 +1,10 @@ +package com.nonid.effect; + +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; + +public class FilledStatusEffect extends StatusEffect { + public FilledStatusEffect() { + super(StatusEffectCategory.HARMFUL, 5913124); + } +} diff --git a/src/main/java/com/nonid/effect/NonStatusEffects.java b/src/main/java/com/nonid/effect/NonStatusEffects.java new file mode 100644 index 0000000..e8c7740 --- /dev/null +++ b/src/main/java/com/nonid/effect/NonStatusEffects.java @@ -0,0 +1,30 @@ +package com.nonid.effect; + +import com.nonid.NeedsOfNature; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; + +public final class NonStatusEffects { + public static final EnergyStatusEffect ENERGY = register("energy", new EnergyStatusEffect()); + public static final EnergyReliefStatusEffect ENERGY_RELIEF = register("energy_relief", new EnergyReliefStatusEffect()); + public static final StatusEffect FERTILITY_INCREASER = register("fertility_increaser", new FertilityIncreaserStatusEffect()); + public static final StatusEffect FILLED = register("filled", new FilledStatusEffect()); + public static final StatusEffect ENERGIZED = register("energized", new EnergizedStatusEffect()); + public static final StatusEffect PREGNANT = register("pregnant", new PregnantStatusEffect()); + + private NonStatusEffects() { + } + + public static void registerAll() { + } + + public static void clearRuntimeState() { + ENERGY.clearRuntimeState(); + ENERGY_RELIEF.clearRuntimeState(); + } + + private static T register(String path, T effect) { + return (T)Registry.register(Registries.STATUS_EFFECT, NeedsOfNature.id(path), effect); + } +} diff --git a/src/main/java/com/nonid/effect/PregnantStatusEffect.java b/src/main/java/com/nonid/effect/PregnantStatusEffect.java new file mode 100644 index 0000000..6e9e9dc --- /dev/null +++ b/src/main/java/com/nonid/effect/PregnantStatusEffect.java @@ -0,0 +1,10 @@ +package com.nonid.effect; + +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.entity.effect.StatusEffectCategory; + +public class PregnantStatusEffect extends StatusEffect { + public PregnantStatusEffect() { + super(StatusEffectCategory.HARMFUL, 13611389); + } +} diff --git a/src/main/java/com/nonid/entity/HorseLiquidCollectorEntity.java b/src/main/java/com/nonid/entity/HorseLiquidCollectorEntity.java new file mode 100644 index 0000000..a9f9874 --- /dev/null +++ b/src/main/java/com/nonid/entity/HorseLiquidCollectorEntity.java @@ -0,0 +1,335 @@ +package com.nonid.entity; + +import com.nonid.EnergyHolder; +import com.nonid.NeedsOfNature; +import com.nonid.particle.NonParticles; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.attribute.EntityAttributes; +import net.minecraft.entity.attribute.DefaultAttributeContainer.Builder; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.mob.PathAwareEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import software.bernie.geckolib.animatable.GeoEntity; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animatable.manager.AnimatableManager.ControllerRegistrar; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class HorseLiquidCollectorEntity extends PathAwareEntity implements GeoEntity { + private static final TrackedData COLLECTOR_FULL = DataTracker.registerData(HorseLiquidCollectorEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + private static final String LIQUID_TYPE_NBT = "NeedsOfNatureCollectorLiquidType"; + private static final int FIXED_ENERGY_VALUE = 70; + private static final int FULL_DRIP_INTERVAL_TICKS = 60; + private static final int NATURAL_REGEN_INTERVAL_TICKS = 80; + private static final float NATURAL_REGEN_AMOUNT = 1.0F; + private static final Vec3d FULL_DRIP_LOCAL_OFFSET = new Vec3d(0.0, 0.6, -0.55); + private AnimatableInstanceCache animatableCache; + @Nullable + private Identifier storedLiquidEntityTypeId; + private boolean storedMixedLiquid; + private boolean orientationLocked; + private float lockedYaw; + private float lockedPitch; + private long nextFullDripTick = Long.MIN_VALUE; + + public HorseLiquidCollectorEntity(EntityType entityType, World world) { + super(entityType, world); + } + + public static Builder createAttributes() { + return MobEntity.createMobAttributes().add(EntityAttributes.MAX_HEALTH, 2.0).add(EntityAttributes.MOVEMENT_SPEED, 0.0); + } + + protected void initGoals() { + } + + protected void initDataTracker(net.minecraft.entity.data.DataTracker.Builder builder) { + super.initDataTracker(builder); + builder.add(COLLECTOR_FULL, false); + } + + public void tickMovement() { + super.tickMovement(); + this.freezeStationaryState(); + } + + public void tick() { + super.tick(); + this.enforceFixedEnergy(); + if (!this.getEntityWorld().isClient() && this.deathTime > 0) { + this.discard(); + } else { + this.tickNaturalRegen(); + this.freezeStationaryState(); + this.tickFullDripParticles(); + } + } + + public boolean damage(ServerWorld world, DamageSource source, float amount) { + if (!this.isAlive() || this.isRemoved()) { + return false; + } + + if (!this.isInvulnerableTo(world, source) && !(amount <= 0.0F)) { + if (!(this.getHealth() - amount <= 0.0F)) { + return super.damage(world, source, amount); + } + + if (!(source.getAttacker() instanceof PlayerEntity player && player.isCreative())) { + this.dropStack(world, new ItemStack(NeedsOfNature.HORSE_LIQUID_COLLECTOR)); + } + + this.discard(); + return true; + } else { + return false; + } + } + + public boolean isPushable() { + return false; + } + + public void pushAwayFrom(Entity entity) { + } + + public void addVelocity(double deltaX, double deltaY, double deltaZ) { + } + + public void takeKnockback(double strength, double x, double z) { + } + + public boolean isPushedByFluids() { + return false; + } + + public boolean canImmediatelyDespawn(double distanceSquared) { + return false; + } + + public void writeData(WriteView view) { + super.writeData(view); + if (this.isCollectorFull()) { + if (this.storedMixedLiquid) { + view.putString("NeedsOfNatureCollectorLiquidType", "mixed"); + } else { + if (this.storedLiquidEntityTypeId != null) { + view.putString("NeedsOfNatureCollectorLiquidType", this.storedLiquidEntityTypeId.toString()); + } + } + } + } + + public void readData(ReadView view) { + super.readData(view); + String raw = view.getOptionalString("NeedsOfNatureCollectorLiquidType").orElse(""); + if (raw.isBlank()) { + this.setStoredEmpty(); + } else if ("mixed".equalsIgnoreCase(raw)) { + this.setStoredMixedLiquid(); + } else { + Identifier parsed = Identifier.tryParse(raw); + if (parsed == null) { + this.setStoredEmpty(); + } else { + this.setStoredEntityLiquid(parsed); + this.ensureOrientationLock(); + } + } + } + + public boolean isCollectorFull() { + return (Boolean)this.dataTracker.get(COLLECTOR_FULL); + } + + @Nullable + public Identifier getBottleLiquidEntityTypeId() { + return this.isCollectorFull() && !this.storedMixedLiquid ? this.storedLiquidEntityTypeId : null; + } + + public void clearStoredLiquid() { + this.setStoredEmpty(); + } + + public void setLockedOrientation(float yaw, float pitch) { + this.orientationLocked = true; + this.lockedYaw = yaw; + this.lockedPitch = pitch; + this.applyLockedOrientation(); + } + + public boolean absorbMixedLiquid() { + if (this.isCollectorFull() && this.storedMixedLiquid) { + return false; + } + + this.setStoredMixedLiquid(); + return true; + } + + public boolean absorbEntityLiquid(@Nullable Identifier donorTypeId) { + if (donorTypeId == null) { + return this.absorbMixedLiquid(); + } + + if (!this.isCollectorFull()) { + this.setStoredEntityLiquid(donorTypeId); + return true; + } + + if (this.storedMixedLiquid) { + return false; + } + + if (donorTypeId.equals(this.storedLiquidEntityTypeId)) { + return false; + } + + this.setStoredMixedLiquid(); + return true; + } + + private void setStoredEntityLiquid(Identifier entityTypeId) { + this.storedLiquidEntityTypeId = entityTypeId; + this.storedMixedLiquid = false; + this.dataTracker.set(COLLECTOR_FULL, true); + this.resetFullDripTimer(); + } + + private void setStoredMixedLiquid() { + this.storedLiquidEntityTypeId = null; + this.storedMixedLiquid = true; + this.dataTracker.set(COLLECTOR_FULL, true); + this.resetFullDripTimer(); + } + + private void setStoredEmpty() { + this.storedLiquidEntityTypeId = null; + this.storedMixedLiquid = false; + this.dataTracker.set(COLLECTOR_FULL, false); + this.nextFullDripTick = Long.MIN_VALUE; + } + + private void freezeStationaryState() { + this.setSilent(true); + this.setNoGravity(true); + this.setAiDisabled(true); + this.getNavigation().stop(); + this.setVelocity(Vec3d.ZERO); + this.fallDistance = 0.0; + this.applyLockedOrientation(); + } + + private void ensureOrientationLock() { + if (!this.orientationLocked) { + this.orientationLocked = true; + this.lockedYaw = this.getYaw(); + this.lockedPitch = this.getPitch(); + } + } + + private void applyLockedOrientation() { + this.ensureOrientationLock(); + this.setYaw(this.lockedYaw); + this.setPitch(this.lockedPitch); + this.setHeadYaw(this.lockedYaw); + this.setBodyYaw(this.lockedYaw); + } + + private void tickFullDripParticles() { + if (this.getEntityWorld() instanceof ServerWorld world) { + if (this.isCollectorFull() && this.isAlive() && !this.isRemoved()) { + long now = world.getTime(); + if (this.nextFullDripTick == Long.MIN_VALUE) { + this.nextFullDripTick = now + 60L; + } + + if (now >= this.nextFullDripTick) { + Vec3d spawnPos = this.resolveFullDripSpawnPos(); + world.spawnParticles( + NonParticles.liquidFalling(NeedsOfNature.resolveBottleLiquidTintRgb(this.getBottleLiquidEntityTypeId())), + spawnPos.x, + spawnPos.y, + spawnPos.z, + 1, + 0.0, + 0.0, + 0.0, + 0.0 + ); + this.nextFullDripTick = now + 60L; + } + } + } + } + + private void resetFullDripTimer() { + if (this.getEntityWorld() instanceof ServerWorld world) { + this.nextFullDripTick = world.getTime() + 60L; + } else { + this.nextFullDripTick = Long.MIN_VALUE; + } + } + + private void tickNaturalRegen() { + if (this.getEntityWorld() instanceof ServerWorld world) { + if (this.isAlive() && !this.isRemoved()) { + if (this.deathTime <= 0 && this.hurtTime <= 0) { + float maxHealth = this.getMaxHealth(); + if (!(this.getHealth() >= maxHealth)) { + if (world.getTime() % 80L == 0L) { + this.heal(1.0F); + } + } + } + } + } + } + + private void enforceFixedEnergy() { + if (this.getEntityWorld() instanceof ServerWorld) { + if (this instanceof EnergyHolder holder) { + if (holder.getEnergy() != 70) { + holder.setEnergy(70); + } + } + } + } + + private Vec3d resolveFullDripSpawnPos() { + double yawRad = Math.toRadians(this.getYaw()); + double sin = Math.sin(yawRad); + double cos = Math.cos(yawRad); + Vec3d forward = new Vec3d(-sin, 0.0, cos); + Vec3d right = new Vec3d(forward.z, 0.0, -forward.x); + return new Vec3d(this.getX(), this.getY(), this.getZ()) + .add(right.multiply(FULL_DRIP_LOCAL_OFFSET.x)) + .add(0.0, FULL_DRIP_LOCAL_OFFSET.y, 0.0) + .add(forward.multiply(FULL_DRIP_LOCAL_OFFSET.z)); + } + + public void registerControllers(@NotNull ControllerRegistrar controllers) { + } + + @NotNull + public AnimatableInstanceCache getAnimatableInstanceCache() { + if (this.animatableCache == null) { + this.animatableCache = GeckoLibUtil.createInstanceCache(this); + } + + return this.animatableCache; + } +} diff --git a/src/main/java/com/nonid/entity/PregnancyEggEntity.java b/src/main/java/com/nonid/entity/PregnancyEggEntity.java new file mode 100644 index 0000000..1ca9ccf --- /dev/null +++ b/src/main/java/com/nonid/entity/PregnancyEggEntity.java @@ -0,0 +1,539 @@ +package com.nonid.entity; + +import com.mojang.serialization.Codec; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import java.util.List; +import java.util.UUID; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityDimensions; +import net.minecraft.entity.EntityPose; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SpawnReason; +import net.minecraft.entity.attribute.EntityAttributeInstance; +import net.minecraft.entity.attribute.EntityAttributes; +import net.minecraft.entity.attribute.DefaultAttributeContainer.Builder; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.mob.PathAwareEntity; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import software.bernie.geckolib.animatable.GeoEntity; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animatable.manager.AnimatableManager.ControllerRegistrar; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class PregnancyEggEntity extends PathAwareEntity implements GeoEntity { + private static final TrackedData SOURCE_ENTITY_TYPE_ID = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.STRING); + private static final TrackedData TARGET_ENTITY_TYPE_ID = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.STRING); + private static final TrackedData HATCH_START_GLOBAL_TICK = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.LONG); + private static final TrackedData HATCH_AT_GLOBAL_TICK = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.LONG); + private static final TrackedData EGG_START_SIZE = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.FLOAT); + private static final TrackedData EGG_END_SIZE = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.FLOAT); + private static final TrackedData EGG_TEXTURE_ID = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.STRING); + private static final TrackedData EGG_HEALTH = DataTracker.registerData(PregnancyEggEntity.class, TrackedDataHandlerRegistry.FLOAT); + private static final String NBT_SOURCE_ENTITY_TYPE = "NeedsOfNatureEggSourceType"; + private static final String NBT_TARGET_ENTITY_TYPE = "NeedsOfNatureEggTargetType"; + private static final String NBT_HATCH_GLOBAL_TICK = "NeedsOfNatureEggHatchGlobalTick"; + private static final String NBT_HATCH_START_GLOBAL_TICK = "NeedsOfNatureEggHatchStartGlobalTick"; + private static final String NBT_OWNER_UUID = "NeedsOfNatureEggOwnerUuid"; + private static final String NBT_VARIANT_DATA = "NeedsOfNatureEggVariantData"; + private static final String NBT_EGG_START_SIZE = "NeedsOfNatureEggStartSize"; + private static final String NBT_EGG_END_SIZE = "NeedsOfNatureEggEndSize"; + private static final String NBT_EGG_TEXTURE_ID = "NeedsOfNatureEggTexture"; + private static final String NBT_EGG_HEALTH = "NeedsOfNatureEggHealth"; + private static final float BASE_DIMENSION = 0.3F; + private AnimatableInstanceCache animatableCache; + private float lastDimensionsScale = -1.0F; + @Nullable + private UUID ownerUuid; + @Nullable + private NeedsOfNature.PregnancyVariantData variantData; + + public PregnancyEggEntity(EntityType entityType, World world) { + super(entityType, world); + } + + public static Builder createAttributes() { + return MobEntity.createMobAttributes().add(EntityAttributes.MAX_HEALTH, 2.0).add(EntityAttributes.MOVEMENT_SPEED, 0.0); + } + + protected void initGoals() { + } + + protected void initDataTracker(net.minecraft.entity.data.DataTracker.Builder builder) { + super.initDataTracker(builder); + builder.add(SOURCE_ENTITY_TYPE_ID, ""); + builder.add(TARGET_ENTITY_TYPE_ID, ""); + builder.add(HATCH_START_GLOBAL_TICK, 0L); + builder.add(HATCH_AT_GLOBAL_TICK, 0L); + builder.add(EGG_START_SIZE, 0.5F); + builder.add(EGG_END_SIZE, 1.0F); + builder.add(EGG_TEXTURE_ID, ""); + builder.add(EGG_HEALTH, 2.0F); + } + + public void configure( + @Nullable UUID ownerPlayerUuid, + @Nullable Identifier sourceEntityTypeId, + @Nullable Identifier targetEntityTypeId, + long hatchAtTick, + @Nullable NeedsOfNature.PregnancyVariantData variantData, + @Nullable NonConfig.EggProfile eggProfile + ) { + this.ownerUuid = ownerPlayerUuid; + this.variantData = NeedsOfNature.normalizePregnancyVariantData(variantData); + this.setSourceEntityTypeId(sourceEntityTypeId); + this.setTargetEntityTypeId(targetEntityTypeId); + this.applyEggProfile(eggProfile); + long startTick = resolveCurrentTick(this.getEntityWorld()); + long endTick = Math.max(0L, hatchAtTick); + if (endTick <= startTick) { + endTick = startTick + 1L; + } + + this.setHatchStartGlobalTick(startTick); + this.setHatchAtGlobalTick(endTick); + this.refreshEggDimensionsIfNeeded(true); + } + + @Nullable + public Identifier getSourceEntityTypeId() { + String raw = (String)this.dataTracker.get(SOURCE_ENTITY_TYPE_ID); + return raw != null && !raw.isBlank() ? Identifier.tryParse(raw) : null; + } + + public void setSourceEntityTypeId(@Nullable Identifier entityTypeId) { + this.dataTracker.set(SOURCE_ENTITY_TYPE_ID, entityTypeId == null ? "" : entityTypeId.toString()); + } + + @Nullable + public Identifier getTargetEntityTypeId() { + String raw = (String)this.dataTracker.get(TARGET_ENTITY_TYPE_ID); + return raw != null && !raw.isBlank() ? Identifier.tryParse(raw) : null; + } + + public void setTargetEntityTypeId(@Nullable Identifier entityTypeId) { + this.dataTracker.set(TARGET_ENTITY_TYPE_ID, entityTypeId == null ? "" : entityTypeId.toString()); + } + + public long getHatchStartGlobalTick() { + return Math.max(0L, (Long)this.dataTracker.get(HATCH_START_GLOBAL_TICK)); + } + + public void setHatchStartGlobalTick(long tick) { + this.dataTracker.set(HATCH_START_GLOBAL_TICK, Math.max(0L, tick)); + } + + public long getHatchAtGlobalTick() { + return Math.max(0L, (Long)this.dataTracker.get(HATCH_AT_GLOBAL_TICK)); + } + + public void setHatchAtGlobalTick(long tick) { + this.dataTracker.set(HATCH_AT_GLOBAL_TICK, Math.max(0L, tick)); + } + + public float getEggStartSize() { + return clampEggSize((Float)this.dataTracker.get(EGG_START_SIZE)); + } + + public void setEggStartSize(float size) { + this.dataTracker.set(EGG_START_SIZE, clampEggSize(size)); + this.refreshEggDimensionsIfNeeded(true); + } + + public float getEggEndSize() { + return clampEggSize((Float)this.dataTracker.get(EGG_END_SIZE)); + } + + public void setEggEndSize(float size) { + this.dataTracker.set(EGG_END_SIZE, clampEggSize(size)); + this.refreshEggDimensionsIfNeeded(true); + } + + @Nullable + public Identifier getEggTextureId() { + String raw = (String)this.dataTracker.get(EGG_TEXTURE_ID); + if (raw != null && !raw.isBlank()) { + Identifier parsed = Identifier.tryParse(raw); + return parsed != null && !parsed.getPath().isBlank() ? parsed : null; + } else { + return null; + } + } + + public void setEggTextureId(@Nullable Identifier textureId) { + this.dataTracker.set(EGG_TEXTURE_ID, textureId != null && !textureId.getPath().isBlank() ? textureId.toString() : ""); + } + + public float getEggHealth() { + return clampEggHealth((Float)this.dataTracker.get(EGG_HEALTH)); + } + + public void setEggHealth(float health) { + this.setEggHealth(health, false); + } + + private void setEggHealth(float health, boolean healToMax) { + float clamped = clampEggHealth(health); + this.dataTracker.set(EGG_HEALTH, clamped); + this.applyEggHealth(clamped, healToMax); + } + + public void tickMovement() { + super.tickMovement(); + this.freezeStationaryState(); + this.refreshEggDimensionsIfNeeded(false); + } + + public void tick() { + super.tick(); + this.freezeStationaryState(); + this.refreshEggDimensionsIfNeeded(false); + this.pushAwayFromNearbyEggs(); + if (this.getEntityWorld() instanceof ServerWorld serverWorld) { + Identifier targetTypeId = this.getTargetEntityTypeId(); + if (targetTypeId == null) { + this.discard(); + } else if (resolveGlobalTick(serverWorld) >= this.getHatchAtGlobalTick()) { + this.hatch(serverWorld, targetTypeId); + } + } + } + + public boolean isPushable() { + return true; + } + + public void pushAwayFrom(Entity entity) { + if (entity instanceof PregnancyEggEntity) { + super.pushAwayFrom(entity); + } + } + + public void addVelocity(double deltaX, double deltaY, double deltaZ) { + super.addVelocity(deltaX, deltaY, deltaZ); + } + + public void takeKnockback(double strength, double x, double z) { + } + + public boolean isPushedByFluids() { + return false; + } + + public boolean canImmediatelyDespawn(double distanceSquared) { + return false; + } + + public void writeData(WriteView view) { + super.writeData(view); + Identifier source = this.getSourceEntityTypeId(); + if (source != null) { + view.putString("NeedsOfNatureEggSourceType", source.toString()); + } + + Identifier target = this.getTargetEntityTypeId(); + if (target != null) { + view.putString("NeedsOfNatureEggTargetType", target.toString()); + } + + view.put("NeedsOfNatureEggHatchStartGlobalTick", Codec.LONG, this.getHatchStartGlobalTick()); + view.put("NeedsOfNatureEggHatchGlobalTick", Codec.LONG, this.getHatchAtGlobalTick()); + if (this.ownerUuid != null) { + view.putString("NeedsOfNatureEggOwnerUuid", this.ownerUuid.toString()); + } + + String variantJson = NeedsOfNature.encodePregnancyVariantData(this.variantData); + if (variantJson != null) { + view.putString("NeedsOfNatureEggVariantData", variantJson); + } + + view.putFloat("NeedsOfNatureEggStartSize", this.getEggStartSize()); + view.putFloat("NeedsOfNatureEggEndSize", this.getEggEndSize()); + Identifier textureId = this.getEggTextureId(); + if (textureId != null) { + view.putString("NeedsOfNatureEggTexture", textureId.toString()); + } + + view.putFloat("NeedsOfNatureEggHealth", this.getEggHealth()); + } + + public void readData(ReadView view) { + super.readData(view); + String sourceRaw = view.getOptionalString("NeedsOfNatureEggSourceType").orElse(""); + this.setSourceEntityTypeId(Identifier.tryParse(sourceRaw)); + String targetRaw = view.getOptionalString("NeedsOfNatureEggTargetType").orElse(""); + this.setTargetEntityTypeId(Identifier.tryParse(targetRaw)); + long start = Math.max(0L, view.read("NeedsOfNatureEggHatchStartGlobalTick", Codec.LONG).orElse(0L)); + long end = Math.max(0L, view.read("NeedsOfNatureEggHatchGlobalTick", Codec.LONG).orElse(0L)); + if (end > 0L && start >= end) { + start = Math.max(0L, end - 1L); + } + + this.setHatchStartGlobalTick(start); + this.setHatchAtGlobalTick(end); + String ownerRaw = view.getOptionalString("NeedsOfNatureEggOwnerUuid").orElse(""); + this.ownerUuid = parseUuid(ownerRaw); + this.variantData = NeedsOfNature.decodePregnancyVariantData(view.getOptionalString("NeedsOfNatureEggVariantData").orElse("")); + this.setEggStartSize(view.read("NeedsOfNatureEggStartSize", Codec.FLOAT).orElse(0.5F)); + this.setEggEndSize(view.read("NeedsOfNatureEggEndSize", Codec.FLOAT).orElse(1.0F)); + this.setEggTextureId(Identifier.tryParse(view.getOptionalString("NeedsOfNatureEggTexture").orElse(""))); + this.setEggHealth(view.read("NeedsOfNatureEggHealth", Codec.FLOAT).orElse(2.0F)); + this.refreshEggDimensionsIfNeeded(true); + } + + @Nullable + private static UUID parseUuid(@Nullable String raw) { + if (raw != null && !raw.isBlank()) { + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + return null; + } + } else { + return null; + } + } + + private void hatch(ServerWorld world, Identifier targetTypeId) { + if (!Registries.ENTITY_TYPE.containsId(targetTypeId)) { + this.discard(); + } else { + EntityType entityType = (EntityType)Registries.ENTITY_TYPE.get(targetTypeId); + Entity entity = entityType.create(world, SpawnReason.BREEDING); + if (entity == null) { + this.discard(); + } else { + entity.refreshPositionAndAngles(this.getX(), this.getY(), this.getZ(), this.getYaw(), this.getPitch()); + if (entity instanceof MobEntity mob) { + mob.initialize(world, world.getLocalDifficulty(mob.getBlockPos()), SpawnReason.BREEDING, null); + NeedsOfNature.applyPregnancyVariantDataForEgg(world, mob, this.variantData); + NeedsOfNature.applyPregnancyMobFlagsForEgg(mob, this.resolveOwnerPlayer(world)); + } + + if (world.spawnEntity(entity)) { + this.playHatchEffects(world); + } + + this.discard(); + } + } + } + + public float getGrowthScale(float partialTick) { + long startTick = this.getHatchStartGlobalTick(); + long endTick = this.getHatchAtGlobalTick(); + float startSize = this.getEggStartSize(); + float endSize = this.getEggEndSize(); + if (endTick <= startTick) { + return endSize; + } + + double now = (float)resolveCurrentTick(this.getEntityWorld()) + Math.max(0.0F, partialTick); + double progress = (now - startTick) / (endTick - startTick); + progress = MathHelper.clamp(progress, 0.0, 1.0); + return (float)(startSize + (endSize - startSize) * progress); + } + + protected EntityDimensions getBaseDimensions(EntityPose pose) { + float scale = this.getGrowthScale(0.0F); + return EntityDimensions.fixed(0.3F * scale, 0.3F * scale); + } + + public void onTrackedDataSet(TrackedData data) { + super.onTrackedDataSet(data); + if (EGG_START_SIZE.equals(data) || EGG_END_SIZE.equals(data)) { + this.refreshEggDimensionsIfNeeded(true); + } + + if (EGG_HEALTH.equals(data)) { + this.applyEggHealth(this.getEggHealth(), false); + } + } + + private void applyEggProfile(@Nullable NonConfig.EggProfile eggProfile) { + NonConfig.EggProfile safe = eggProfile == null ? NonConfig.EggProfile.defaults() : eggProfile.resolvedOver(NonConfig.EggProfile.defaults()); + Float startSize = safe.startSize(); + Float endSize = safe.endSize(); + Float health = safe.health(); + this.setEggStartSize(startSize == null ? 0.5F : startSize); + this.setEggEndSize(endSize == null ? 1.0F : endSize); + String texture = safe.texture(); + this.setEggTextureId(texture == null ? null : Identifier.tryParse(texture)); + this.setEggHealth(health == null ? 2.0F : health, true); + } + + private void applyEggHealth(float health, boolean healToMax) { + EntityAttributeInstance attribute = this.getAttributeInstance(EntityAttributes.MAX_HEALTH); + if (attribute != null && Math.abs(attribute.getBaseValue() - health) > 0.001) { + attribute.setBaseValue(health); + } + + if (healToMax || this.getHealth() > health || this.getHealth() <= 0.0F) { + this.setHealth(health); + } + } + + private void refreshEggDimensionsIfNeeded(boolean force) { + float scale = this.getGrowthScale(0.0F); + if (force || !(Math.abs(scale - this.lastDimensionsScale) < 0.01F)) { + this.lastDimensionsScale = scale; + this.calculateDimensions(); + } + } + + private static float clampEggSize(float value) { + return !Float.isFinite(value) ? 1.0F : MathHelper.clamp(value, 0.05F, 4.0F); + } + + private static float clampEggHealth(float value) { + return !Float.isFinite(value) ? 2.0F : MathHelper.clamp(value, 0.5F, 100.0F); + } + + private void playHatchEffects(ServerWorld world) { + if (world != null) { + world.playSound( + null, + this.getX(), + this.getY(), + this.getZ(), + SoundEvents.ENTITY_TURTLE_EGG_HATCH, + SoundCategory.NEUTRAL, + 0.9F, + 0.95F + world.getRandom().nextFloat() * 0.1F + ); + world.spawnParticles(ParticleTypes.POOF, this.getX(), this.getY() + 0.25, this.getZ(), 12, 0.25, 0.2, 0.25, 0.02); + world.spawnParticles(ParticleTypes.CLOUD, this.getX(), this.getY() + 0.2, this.getZ(), 8, 0.18, 0.12, 0.18, 0.01); + } + } + + @Nullable + private ServerPlayerEntity resolveOwnerPlayer(ServerWorld world) { + return this.ownerUuid != null && world.getServer() != null ? world.getServer().getPlayerManager().getPlayer(this.ownerUuid) : null; + } + + private static long resolveGlobalTick(ServerWorld world) { + if (world != null && world.getServer() != null) { + ServerWorld overworld = world.getServer().getOverworld(); + return overworld == null ? world.getTime() : overworld.getTime(); + } else { + return 0L; + } + } + + private static long resolveCurrentTick(@Nullable World world) { + if (world == null) { + return 0L; + } else { + return world instanceof ServerWorld serverWorld ? resolveGlobalTick(serverWorld) : world.getTime(); + } + } + + private void freezeStationaryState() { + this.setSilent(true); + this.setNoGravity(true); + this.setAiDisabled(true); + this.getNavigation().stop(); + Vec3d velocity = this.getVelocity(); + double x = MathHelper.clamp(velocity.x * 0.65, -0.08, 0.08); + double z = MathHelper.clamp(velocity.z * 0.65, -0.08, 0.08); + if (Math.abs(x) < 0.001) { + x = 0.0; + } + + if (Math.abs(z) < 0.001) { + z = 0.0; + } + + this.setVelocity(x, 0.0, z); + this.fallDistance = 0.0; + } + + private void pushAwayFromNearbyEggs() { + if (!this.isRemoved()) { + Box searchBox = this.getBoundingBox().expand(0.05, 0.0, 0.05); + List nearbyEggs = this.getEntityWorld() + .getEntitiesByClass(PregnancyEggEntity.class, searchBox, egg -> egg != this && !egg.isRemoved()); + if (!nearbyEggs.isEmpty()) { + double pushX = 0.0; + double pushZ = 0.0; + double ownRadius = Math.max(0.1, this.getBoundingBox().getLengthX() * 0.5); + + for (PregnancyEggEntity other : nearbyEggs) { + double otherRadius = Math.max(0.1, other.getBoundingBox().getLengthX() * 0.5); + double minDistance = ownRadius + otherRadius + 0.04; + double dx = this.getX() - other.getX(); + double dz = this.getZ() - other.getZ(); + double distanceSq = dx * dx + dz * dz; + if (!(distanceSq >= minDistance * minDistance)) { + if (distanceSq < 1.0E-6) { + double angle = this.deterministicSeparationAngle(); + dx = Math.cos(angle); + dz = Math.sin(angle); + distanceSq = 1.0; + } + + double distance = Math.sqrt(distanceSq); + if (!(distance <= 0.0)) { + double strength = Math.min(0.08, (minDistance - distance) * 0.15); + pushX += dx / distance * strength; + pushZ += dz / distance * strength; + } + } + } + + if (!(Math.abs(pushX) < 1.0E-5) || !(Math.abs(pushZ) < 1.0E-5)) { + pushX = MathHelper.clamp(pushX, -0.08, 0.08); + pushZ = MathHelper.clamp(pushZ, -0.08, 0.08); + this.updatePosition(this.getX() + pushX, this.getY(), this.getZ() + pushZ); + this.setVelocity(pushX * 0.25, 0.0, pushZ * 0.25); + this.applySettledPushRotation(pushX, pushZ); + } + } + } + } + + private double deterministicSeparationAngle() { + long bits = this.getUuid().getMostSignificantBits() ^ this.getUuid().getLeastSignificantBits(); + double unit = (bits & 65535L) / 65536.0; + return unit * Math.PI * 2.0; + } + + private void applySettledPushRotation(double pushX, double pushZ) { + if (!(Math.abs(pushX) < 1.0E-5) || !(Math.abs(pushZ) < 1.0E-5)) { + float yaw = (float)(Math.atan2(pushZ, pushX) * (180.0 / Math.PI)) - 90.0F; + this.setYaw(yaw); + this.setHeadYaw(yaw); + this.setBodyYaw(yaw); + this.lastYaw = yaw; + } + } + + public void registerControllers(@NotNull ControllerRegistrar controllers) { + } + + @NotNull + public AnimatableInstanceCache getAnimatableInstanceCache() { + if (this.animatableCache == null) { + this.animatableCache = GeckoLibUtil.createInstanceCache(this); + } + + return this.animatableCache; + } +} diff --git a/src/main/java/com/nonid/integration/FloatValueSliderWidget.java b/src/main/java/com/nonid/integration/FloatValueSliderWidget.java new file mode 100644 index 0000000..65367e2 --- /dev/null +++ b/src/main/java/com/nonid/integration/FloatValueSliderWidget.java @@ -0,0 +1,87 @@ +package com.nonid.integration; + +import java.util.Locale; +import java.util.function.DoubleConsumer; +import java.util.function.DoubleFunction; +import net.minecraft.client.gui.widget.SliderWidget; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +final class FloatValueSliderWidget extends SliderWidget { + private final double min; + private final double max; + private final double step; + private final DoubleConsumer listener; + private final DoubleFunction formatter; + private float floatValue; + private double warningAbove = Double.POSITIVE_INFINITY; + + FloatValueSliderWidget(int x, int y, int width, int height, float initialValue, float min, float max, float step, DoubleConsumer listener) { + this(x, y, width, height, initialValue, min, max, step, listener, FloatValueSliderWidget::format); + } + + FloatValueSliderWidget( + int x, int y, int width, int height, float initialValue, float min, float max, float step, DoubleConsumer listener, DoubleFunction formatter + ) { + super(x, y, width, height, Text.literal(""), toSliderValue(initialValue, min, max)); + this.min = min; + this.max = max; + this.step = Math.max(1.0E-4, step); + this.listener = listener; + this.formatter = formatter == null ? FloatValueSliderWidget::format : formatter; + this.floatValue = this.clampAndRound(initialValue); + this.value = toSliderValue(this.floatValue, min, max); + this.updateMessage(); + } + + float getFloatValue() { + return this.floatValue; + } + + void setFloatValue(float value) { + this.floatValue = this.clampAndRound(value); + this.value = toSliderValue(this.floatValue, this.min, this.max); + this.updateMessage(); + if (this.listener != null) { + this.listener.accept(this.floatValue); + } + } + + void setWarningAbove(float value) { + this.warningAbove = value; + this.updateMessage(); + } + + protected void updateMessage() { + this.floatValue = this.clampAndRound((float)(this.min + this.value * (this.max - this.min))); + MutableText message = Text.literal(this.formatter.apply(this.floatValue)); + if (this.floatValue > this.warningAbove) { + message.formatted(Formatting.YELLOW); + } + + this.setMessage(message); + } + + protected void applyValue() { + this.floatValue = this.clampAndRound((float)(this.min + this.value * (this.max - this.min))); + this.value = toSliderValue(this.floatValue, this.min, this.max); + if (this.listener != null) { + this.listener.accept(this.floatValue); + } + } + + private float clampAndRound(float input) { + double clamped = Math.max(this.min, Math.min(this.max, input)); + double rounded = Math.round(clamped / this.step) * this.step; + return (float)Math.max(this.min, Math.min(this.max, rounded)); + } + + private static double toSliderValue(double value, double min, double max) { + return max <= min ? 0.0 : Math.max(0.0, Math.min(1.0, (value - min) / (max - min))); + } + + private static String format(double value) { + return String.format(Locale.ROOT, "%.3f", value).replaceAll("0+$", "").replaceAll("\\.$", ""); + } +} diff --git a/src/main/java/com/nonid/integration/NonCreditsButtonHooks.java b/src/main/java/com/nonid/integration/NonCreditsButtonHooks.java new file mode 100644 index 0000000..8f57aab --- /dev/null +++ b/src/main/java/com/nonid/integration/NonCreditsButtonHooks.java @@ -0,0 +1,41 @@ +package com.nonid.integration; + +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AfterInit; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.text.Text; + +public final class NonCreditsButtonHooks { + private static final int BUTTON_WIDTH = 52; + private static final int EDGE_MARGIN = 8; + private static boolean registered; + + private NonCreditsButtonHooks() { + } + + public static void register() { + if (!registered) { + registered = true; + ScreenEvents.AFTER_INIT + .register( + (AfterInit)(client, screen, scaledWidth, scaledHeight) -> { + if (isNoNSettingsScreen(screen)) { + NonModMenuScreens.FlatTextButton creditsButton = new NonModMenuScreens.FlatTextButton( + 52, Text.translatable("config.needsofnature.category.credits"), button -> client.setScreen(new NonCreditsScreen(screen)) + ); + creditsButton.setX(scaledWidth - 52 - 8); + creditsButton.setY(scaledHeight - creditsButton.getHeight() - 8); + creditsButton.setAlpha(0.7F); + NonModMenuScreens.setTooltip(creditsButton, "config.needsofnature.tooltip.category.credits"); + Screens.getButtons(screen).add(creditsButton); + } + } + ); + } + } + + private static boolean isNoNSettingsScreen(Screen screen) { + return screen != null && screen.getClass().getPackageName().equals(NonCreditsButtonHooks.class.getPackageName()) && !(screen instanceof NonCreditsScreen); + } +} diff --git a/src/main/java/com/nonid/integration/NonCreditsData.java b/src/main/java/com/nonid/integration/NonCreditsData.java new file mode 100644 index 0000000..ef6368a --- /dev/null +++ b/src/main/java/com/nonid/integration/NonCreditsData.java @@ -0,0 +1,197 @@ +package com.nonid.integration; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import com.nonid.NeedsOfNature; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +final class NonCreditsData { + private static final Gson GSON = new Gson(); + private static final String CREDITS_RESOURCE = "/assets/needsofnature/credits.json"; + + private NonCreditsData() { + } + + static NonCreditsData.Credits load() { + try (InputStream stream = NonCreditsData.class.getResourceAsStream("/assets/needsofnature/credits.json")) { + if (stream == null) { + NeedsOfNature.LOGGER.warn("[NoN] Bundled credits file is missing: {}", "/assets/needsofnature/credits.json"); + return fallback(); + } else { + NonCreditsData.RawCredits raw = (NonCreditsData.RawCredits)GSON.fromJson( + new InputStreamReader(stream, StandardCharsets.UTF_8), NonCreditsData.RawCredits.class + ); + NonCreditsData.Credits credits = normalize(raw); + if (credits.notice() == null && credits.sections().isEmpty() && credits.resources().isEmpty() && credits.projectLinks().isEmpty()) { + NeedsOfNature.LOGGER.warn("[NoN] Bundled credits file contains no valid entries."); + return fallback(); + } else { + return credits; + } + } + } catch (Exception e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to read bundled credits; using fallback credits.", e); + return fallback(); + } + } + + private static NonCreditsData.Credits normalize(NonCreditsData.RawCredits raw) { + if (raw == null) { + return new NonCreditsData.Credits(null, List.of(), List.of(), List.of()); + } + + List sections = new ArrayList<>(); + if (raw.sections != null) { + for (NonCreditsData.RawSection rawSection : raw.sections) { + if (rawSection != null && !blank(rawSection.title)) { + List contributors = new ArrayList<>(); + if (rawSection.contributors != null) { + for (NonCreditsData.RawContributor rawContributor : rawSection.contributors) { + if (rawContributor != null && !blank(rawContributor.name)) { + contributors.add( + new NonCreditsData.Contributor(rawContributor.name.trim(), cleanStrings(rawContributor.roles), clean(rawContributor.contribution)) + ); + } + } + } + + if (!contributors.isEmpty()) { + sections.add(new NonCreditsData.Section(rawSection.title.trim(), List.copyOf(contributors))); + } + } + } + } + + List resources = new ArrayList<>(); + if (raw.resources != null) { + for (NonCreditsData.RawResource rawResource : raw.resources) { + if (rawResource != null && !blank(rawResource.title)) { + resources.add( + new NonCreditsData.ResourceCredit( + clean(rawResource.category), + rawResource.title.trim(), + cleanStrings(rawResource.assets), + clean(rawResource.creator), + clean(rawResource.source), + validWebUrl(rawResource.sourceUrl), + clean(rawResource.license), + validWebUrl(rawResource.licenseUrl), + rawResource.modified + ) + ); + } + } + } + + List projectLinks = new ArrayList<>(); + if (raw.projectLinks != null) { + for (NonCreditsData.RawLink rawLink : raw.projectLinks) { + if (rawLink != null && !blank(rawLink.label)) { + String url = validWebUrl(rawLink.url); + if (url != null) { + projectLinks.add(new NonCreditsData.ProjectLink(rawLink.label.trim(), url)); + } + } + } + } + + return new NonCreditsData.Credits(clean(raw.notice), List.copyOf(sections), List.copyOf(resources), List.copyOf(projectLinks)); + } + + private static String validWebUrl(String value) { + if (blank(value)) { + return null; + } + + try { + URI uri = URI.create(value.trim()); + String scheme = uri.getScheme(); + if (uri.getHost() != null && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + return uri.toString(); + } + } catch (IllegalArgumentException var3) { + } + + NeedsOfNature.LOGGER.warn("[NoN] Ignoring invalid credits URL: {}", value); + return null; + } + + private static List cleanStrings(List values) { + return values != null && !values.isEmpty() ? values.stream().filter(value -> !blank(value)).map(String::trim).toList() : List.of(); + } + + private static String clean(String value) { + return blank(value) ? null : value.trim(); + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + + private static NonCreditsData.Credits fallback() { + NonCreditsData.Contributor contributor = new NonCreditsData.Contributor("L1Z0", List.of("Project Lead", "Programming"), null); + return new NonCreditsData.Credits(null, List.of(new NonCreditsData.Section("Development", List.of(contributor))), List.of(), List.of()); + } + + record Contributor(String name, List roles, String contribution) { + } + + record Credits( + String notice, List sections, List resources, List projectLinks + ) { + } + + record ProjectLink(String label, String url) { + } + + private static final class RawContributor { + private String name; + private List roles; + private String contribution; + } + + private static final class RawCredits { + private String notice; + private List sections; + private List resources; + @SerializedName("project_links") + private List projectLinks; + } + + private static final class RawLink { + private String label; + private String url; + } + + private static final class RawResource { + private String category; + private String title; + private List assets; + private String creator; + private String source; + @SerializedName("source_url") + private String sourceUrl; + private String license; + @SerializedName("license_url") + private String licenseUrl; + private boolean modified; + } + + private static final class RawSection { + private String title; + private List contributors; + } + + record ResourceCredit( + String category, String title, List assets, String creator, String source, String sourceUrl, String license, String licenseUrl, boolean modified + ) { + } + + record Section(String title, List contributors) { + } +} diff --git a/src/main/java/com/nonid/integration/NonCreditsScreen.java b/src/main/java/com/nonid/integration/NonCreditsScreen.java new file mode 100644 index 0000000..b85a6db --- /dev/null +++ b/src/main/java/com/nonid/integration/NonCreditsScreen.java @@ -0,0 +1,402 @@ +package com.nonid.integration; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.screen.ConfirmLinkScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.ElementListWidget.Entry; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; + +final class NonCreditsScreen extends Screen { + private final Screen parent; + private final NonCreditsData.Credits credits; + + NonCreditsScreen(Screen parent) { + super(Text.translatable("config.needsofnature.credits_title")); + this.parent = parent; + this.credits = NonCreditsData.load(); + } + + protected void init() { + int listTop = 42; + int listHeight = Math.max(0, this.height - listTop - 40); + NonCreditsScreen.CreditsList creditsList = new NonCreditsScreen.CreditsList( + this.client, this.width, listHeight, listTop, this.textRenderer, url -> ConfirmLinkScreen.open(this, url) + ); + creditsList.setCredits(this.credits); + this.addDrawableChild(creditsList); + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(this.width / 2 - 100, this.height - 28, 200, 20) + .build() + ); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 10, -1); + context.drawCenteredTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.credits_intro"), this.width / 2, 24, -4671304); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private record ContributorRow(NonCreditsData.Contributor contributor) implements NonCreditsScreen.CreditsRow { + } + + private static final class CreditsList extends ElementListWidget { + private final TextRenderer textRenderer; + private final Consumer linkOpener; + + private CreditsList(MinecraftClient client, int width, int height, int top, TextRenderer textRenderer, Consumer linkOpener) { + super(client, width, height, top, 28); + this.textRenderer = textRenderer; + this.linkOpener = linkOpener; + this.centerListVertically = false; + } + + private void setCredits(NonCreditsData.Credits credits) { + this.clearEntries(); + int contentWidth = Math.max(100, this.getRowWidth() - 16); + if (credits.notice() != null) { + this.addRow(new NonCreditsScreen.NoticeRow(credits.notice()), contentWidth); + } + + for (NonCreditsData.Section section : credits.sections()) { + this.addRow(new NonCreditsScreen.SectionRow(Text.literal(section.title())), contentWidth); + + for (NonCreditsData.Contributor contributor : section.contributors()) { + this.addRow(new NonCreditsScreen.ContributorRow(contributor), contentWidth); + } + } + + if (!credits.resources().isEmpty()) { + this.addRow(new NonCreditsScreen.SectionRow(Text.translatable("config.needsofnature.credits.resource_credits")), contentWidth); + + for (NonCreditsData.ResourceCredit resource : credits.resources()) { + this.addRow(new NonCreditsScreen.ResourceRow(resource), contentWidth); + } + } + + if (!credits.projectLinks().isEmpty()) { + this.addRow(new NonCreditsScreen.SectionRow(Text.translatable("config.needsofnature.credits.project_links")), contentWidth); + this.addRow(new NonCreditsScreen.LinksRow(credits.projectLinks()), contentWidth); + } + } + + private void addRow(NonCreditsScreen.CreditsRow row, int contentWidth) { + NonCreditsScreen.CreditsList.RowEntry entry = new NonCreditsScreen.CreditsList.RowEntry(row, this.textRenderer, contentWidth, this.linkOpener); + this.addEntry(entry, entry.rowHeight()); + } + + public int getRowWidth() { + return Math.min(520, Math.max(120, this.getWidth() - 32)); + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + protected int getScrollbarX() { + return this.getWidth() - 8; + } + + private static final class RowEntry extends Entry { + private final NonCreditsScreen.CreditsRow row; + private final TextRenderer textRenderer; + private final int rowHeight; + private final List widgets = new ArrayList<>(); + + private RowEntry(NonCreditsScreen.CreditsRow row, TextRenderer textRenderer, int contentWidth, Consumer linkOpener) { + this.row = row; + this.textRenderer = textRenderer; + this.rowHeight = calculateHeight(row, textRenderer, contentWidth); + if (row instanceof NonCreditsScreen.ResourceRow resourceRow) { + NonCreditsData.ResourceCredit resource = resourceRow.resource(); + this.addLinkButton("config.needsofnature.credits.source", resource.sourceUrl(), linkOpener); + this.addLinkButton("config.needsofnature.credits.license", resource.licenseUrl(), linkOpener); + } else if (row instanceof NonCreditsScreen.LinksRow linksRow) { + for (NonCreditsData.ProjectLink link : linksRow.links()) { + ButtonWidget button = ButtonWidget.builder(Text.literal(link.label()), ignored -> linkOpener.accept(link.url())) + .dimensions(0, 0, 180, 20) + .build(); + NonModMenuScreens.setTooltip(button, NonModMenuScreens.tooltip(Text.literal(link.url()))); + this.widgets.add(button); + } + } + } + + private void addLinkButton(String translationKey, String url, Consumer linkOpener) { + if (url != null) { + ButtonWidget button = ButtonWidget.builder(Text.translatable(translationKey), ignored -> linkOpener.accept(url)) + .dimensions(0, 0, 90, 18) + .build(); + NonModMenuScreens.setTooltip(button, NonModMenuScreens.tooltip(Text.literal(url))); + this.widgets.add(button); + } + } + + private static int calculateHeight(NonCreditsScreen.CreditsRow row, TextRenderer renderer, int contentWidth) { + if (row instanceof NonCreditsScreen.NoticeRow noticeRow) { + int lines = wrappedLineCount(renderer, noticeRow.text(), contentWidth - 16); + return Math.max(34, 16 + lines * 10); + } else if (row instanceof NonCreditsScreen.SectionRow) { + return 30; + } else if (row instanceof NonCreditsScreen.ContributorRow) { + return 18; + } else if (row instanceof NonCreditsScreen.ResourceRow resourceRow) { + NonCreditsData.ResourceCredit resource = resourceRow.resource(); + int detailLines = resourceDetailLines(resource); + String assets = joinedAssets(resource.assets()); + int assetLines = assets == null + ? 0 + : renderer.wrapLines(Text.translatable("config.needsofnature.credits.assets_value", new Object[]{assets}), Math.max(20, contentWidth - 8)) + .size(); + int buttonHeight = resource.sourceUrl() == null && resource.licenseUrl() == null ? 0 : 22; + return 18 + detailLines * 10 + assetLines * 10 + buttonHeight + 6; + } else { + return row instanceof NonCreditsScreen.LinksRow linksRow ? Math.max(26, 6 + linksRow.links().size() * 24) : 28; + } + } + + private static int wrappedLineCount(TextRenderer renderer, String value, int width) { + return value != null && !value.isBlank() ? renderer.wrapLines(Text.literal(value), Math.max(20, width)).size() : 0; + } + + private static int resourceDetailLines(NonCreditsData.ResourceCredit resource) { + int count = 0; + if (resource.category() != null) { + count++; + } + + if (resource.creator() != null) { + count++; + } + + if (resource.source() != null) { + count++; + } + + if (resource.license() != null || resource.modified()) { + count++; + } + + return count; + } + + private static String joinedAssets(List assets) { + return assets != null && !assets.isEmpty() ? String.join(", ", assets) : null; + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int x = this.getContentX() + 8; + int y = this.getContentY() + 3; + int width = this.getContentWidth() - 16; + if (this.row instanceof NonCreditsScreen.NoticeRow noticeRow) { + this.renderNotice(context, x, y, width, noticeRow.text()); + } else if (this.row instanceof NonCreditsScreen.SectionRow sectionRow) { + this.renderSection(context, x, y, width, sectionRow.title()); + } else if (this.row instanceof NonCreditsScreen.ContributorRow contributorRow) { + this.renderContributor(context, x, y, width, contributorRow.contributor()); + } else if (this.row instanceof NonCreditsScreen.ResourceRow resourceRow) { + this.renderResource(context, mouseX, mouseY, deltaTicks, x, y, width, hovered, resourceRow.resource()); + } else if (this.row instanceof NonCreditsScreen.LinksRow) { + this.renderProjectLinks(context, mouseX, mouseY, deltaTicks, x, y, width); + } + } + + private void renderNotice(DrawContext context, int x, int y, int width, String notice) { + context.fill(x, y - 1, x + width, y + this.rowHeight - 5, 1144000528); + int textY = y + 6; + + for (OrderedText line : this.textRenderer.wrapLines(Text.literal(notice), Math.max(20, width - 16))) { + context.drawCenteredTextWithShadow(this.textRenderer, line, x + width / 2, textY, -20304); + textY += 10; + } + } + + private void renderSection(DrawContext context, int x, int y, int width, Text title) { + Text displayedTitle = Text.literal(this.textRenderer.trimToWidth(title.getString(), Math.max(20, width - 40))); + int centerX = x + width / 2; + int textWidth = this.textRenderer.getWidth(displayedTitle); + int lineY = y + 10; + context.fill(x, y + 2, x + width, y + 22, 671088640); + context.fill(x + 6, lineY, Math.max(x + 6, centerX - textWidth / 2 - 10), lineY + 1, 1717197402); + context.fill(Math.min(x + width - 6, centerX + textWidth / 2 + 10), lineY, x + width - 6, lineY + 1, 1717197402); + context.drawCenteredTextWithShadow(this.textRenderer, displayedTitle, centerX, y + 6, -2042444); + } + + private void renderContributor(DrawContext context, int x, int y, int width, NonCreditsData.Contributor contributor) { + int textX = x + 5; + int textY = y + 2; + String prefix = contributor.name(); + String details = contributorDetails(contributor); + if (details != null) { + prefix = prefix + ": "; + } + + prefix = this.textRenderer.trimToWidth(prefix, width - 10); + context.drawTextWithShadow(this.textRenderer, prefix, textX, textY, -796531); + if (details != null) { + int detailX = textX + this.textRenderer.getWidth(prefix); + int detailWidth = Math.max(0, x + width - 5 - detailX); + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(details, detailWidth), detailX, textY, -4671304); + } + } + + private static String contributorDetails(NonCreditsData.Contributor contributor) { + String roles = contributor.roles().isEmpty() ? null : String.join(", ", contributor.roles()); + if (contributor.contribution() == null) { + return roles; + } else { + return roles == null ? contributor.contribution() : roles + " - " + contributor.contribution(); + } + } + + private void renderResource( + DrawContext context, int mouseX, int mouseY, float delta, int x, int y, int width, boolean hovered, NonCreditsData.ResourceCredit resource + ) { + context.fill(x, y - 1, x + width, y + this.rowHeight - 5, hovered ? 939524096 : 570425344); + int textX = x + 5; + int currentY = y + 3; + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(resource.title(), width - 10), textX, currentY, -796531); + currentY += 10; + if (resource.category() != null) { + this.drawTrimmed( + context, + Text.translatable("config.needsofnature.credits.category_value", new Object[]{resource.category()}), + textX, + currentY, + width - 10, + -5592406 + ); + currentY += 10; + } + + if (resource.creator() != null) { + this.drawTrimmed( + context, + Text.translatable("config.needsofnature.credits.creator_value", new Object[]{resource.creator()}), + textX, + currentY, + width - 10, + -3092272 + ); + currentY += 10; + } + + if (resource.source() != null) { + this.drawTrimmed( + context, + Text.translatable("config.needsofnature.credits.source_value", new Object[]{resource.source()}), + textX, + currentY, + width - 10, + -4671304 + ); + currentY += 10; + } + + if (resource.license() != null || resource.modified()) { + Text license = resource.license() == null + ? Text.translatable("config.needsofnature.credits.modified") + : ( + resource.modified() + ? Text.translatable("config.needsofnature.credits.license_modified_value", new Object[]{resource.license()}) + : Text.translatable("config.needsofnature.credits.license_value", new Object[]{resource.license()}) + ); + this.drawTrimmed(context, license, textX, currentY, width - 10, -4671304); + currentY += 10; + } + + String assets = joinedAssets(resource.assets()); + if (assets != null) { + Text assetText = Text.translatable("config.needsofnature.credits.assets_value", new Object[]{assets}); + + for (OrderedText line : this.textRenderer.wrapLines(assetText, Math.max(20, width - 10))) { + context.drawTextWithShadow(this.textRenderer, line, textX, currentY, -7303024); + currentY += 10; + } + } + + if (!this.widgets.isEmpty()) { + int totalWidth = this.widgets.size() * 90 + Math.max(0, this.widgets.size() - 1) * 4; + int buttonX = x + (width - totalWidth) / 2; + int buttonY = y + this.rowHeight - 27; + + for (ClickableWidget widget : this.widgets) { + widget.setX(buttonX); + widget.setY(buttonY); + widget.render(context, mouseX, mouseY, delta); + buttonX += 94; + } + } + } + + private void renderProjectLinks(DrawContext context, int mouseX, int mouseY, float delta, int x, int y, int width) { + int buttonWidth = Math.min(180, width - 8); + int buttonX = x + (width - buttonWidth) / 2; + int buttonY = y + 2; + + for (ClickableWidget widget : this.widgets) { + widget.setWidth(buttonWidth); + widget.setX(buttonX); + widget.setY(buttonY); + widget.render(context, mouseX, mouseY, delta); + buttonY += 24; + } + } + + private void drawTrimmed(DrawContext context, Text value, int x, int y, int width, int color) { + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(value.getString(), Math.max(20, width)), x, y, color); + } + + private int rowHeight() { + return this.rowHeight; + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + } + } + + private sealed interface CreditsRow + permits NonCreditsScreen.NoticeRow, + NonCreditsScreen.SectionRow, + NonCreditsScreen.ContributorRow, + NonCreditsScreen.ResourceRow, + NonCreditsScreen.LinksRow { + } + + private record LinksRow(List links) implements NonCreditsScreen.CreditsRow { + } + + private record NoticeRow(String text) implements NonCreditsScreen.CreditsRow { + } + + private record ResourceRow(NonCreditsData.ResourceCredit resource) implements NonCreditsScreen.CreditsRow { + } + + private record SectionRow(Text title) implements NonCreditsScreen.CreditsRow { + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuDebugScreens.java b/src/main/java/com/nonid/integration/NonModMenuDebugScreens.java new file mode 100644 index 0000000..c35a28a --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuDebugScreens.java @@ -0,0 +1,4450 @@ +package com.nonid.integration; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.NonDebugChatMode; +import com.nonid.client.NonAnimationPreviewRenderer; +import com.nonid.client.NonLiquidColors; +import com.nonid.client.NonServerSettingsClient; +import com.nonid.client.NonTagLocalizer; +import com.nonid.data.NonEntityProfiles; +import com.nonid.data.NonLiquidGainOverrides; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.AnimationEngineClient; +import com.nonid.internal.animation.client.render.gecko.GeckoResourceResolver; +import com.nonid.internal.animation.client.render.gecko.GenderedModelResolver; +import com.nonid.internal.animation.client.render.gecko.VanillaTextureResolver; +import com.nonid.network.AnimationCatalogData; +import com.nonid.pack.NonExternalPackProvider; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.Map.Entry; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.gui.widget.ButtonWidget.PressAction; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.registry.Registries; +import net.minecraft.resource.InputSupplier; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourcePack; +import net.minecraft.server.integrated.IntegratedServer; +import net.minecraft.text.MutableText; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class NonModMenuDebugScreens { + private NonModMenuDebugScreens() { + } + + static class DebugAdvancedScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig localConfig; + private final NonConfig defaults = new NonConfig(); + private boolean serverConfigEditable; + private int scanBudget; + private NonDebugChatMode debugChatMode; + private boolean debugSpinMode; + private boolean convertMaleOnlyVInjectionsToA; + private final boolean initialForceVanillaEntityTextures; + private boolean forceVanillaEntityTextures; + private int animationBlockSearchRadius; + private TextFieldWidget budgetField; + private ButtonWidget debugModeButton; + private ButtonWidget debugSpinModeButton; + private ButtonWidget convertMaleOnlyVInjectionsToAButton; + private ButtonWidget resetBudgetButton; + private ButtonWidget resetDebugModeButton; + private ButtonWidget resetDebugSpinModeButton; + private ButtonWidget resetConvertMaleOnlyVInjectionsToAButton; + private ButtonWidget forceVanillaEntityTexturesButton; + private ButtonWidget animationBlockSearchRadiusButton; + private ButtonWidget resetForceVanillaEntityTexturesButton; + private ButtonWidget resetAnimationBlockSearchRadiusButton; + + protected DebugAdvancedScreen(Screen parent, NonConfig config, NonConfig localConfig) { + super(Text.translatable("config.needsofnature.debug_advanced_title")); + this.parent = parent; + this.config = config; + this.localConfig = localConfig; + this.scanBudget = config.getScanBudgetPerTick(); + this.debugChatMode = localConfig.debugChatMode(); + this.debugSpinMode = localConfig.isDebugSpinMode(); + this.convertMaleOnlyVInjectionsToA = config.convertMaleOnlyVInjectionsToA(); + this.initialForceVanillaEntityTextures = localConfig.forceVanillaEntityTextures(); + this.forceVanillaEntityTextures = this.initialForceVanillaEntityTextures; + this.animationBlockSearchRadius = config.blockSearchRadius(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.budgetField != null) { + this.scanBudget = this.parseField(this.budgetField, this.scanBudget); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.runtime_budget"))); + this.budgetField = this.newNumberField(fieldWidth, this.scanBudget); + this.resetBudgetButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), button -> this.budgetField.setText(String.valueOf(this.defaults.getScanBudgetPerTick())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetBudgetButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.scan_budget"), + this.budgetField, + this.resetBudgetButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.scan_budget") + ) + ); + this.budgetField.setEditable(this.serverConfigEditable); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.debug_feedback"))); + this.debugModeButton = ButtonWidget.builder(this.debugChatLabel(this.debugChatMode), button -> { + this.debugChatMode = this.nextDebugChatMode(this.debugChatMode); + button.setMessage(this.debugChatLabel(this.debugChatMode)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetDebugModeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.debugChatMode = this.defaults.debugChatMode(); + this.debugModeButton.setMessage(this.debugChatLabel(this.debugChatMode)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetDebugModeButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.debug_chat_mode"), + this.debugModeButton, + this.resetDebugModeButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.debug_chat_mode") + ) + ); + this.debugSpinModeButton = ButtonWidget.builder(this.debugSpinModeLabel(this.debugSpinMode), button -> { + this.debugSpinMode = !this.debugSpinMode; + button.setMessage(this.debugSpinModeLabel(this.debugSpinMode)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetDebugSpinModeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.debugSpinMode = this.defaults.isDebugSpinMode(); + this.debugSpinModeButton.setMessage(this.debugSpinModeLabel(this.debugSpinMode)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetDebugSpinModeButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.debug_spin_mode"), + this.debugSpinModeButton, + this.resetDebugSpinModeButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.debug_spin_mode") + ) + ); + this.convertMaleOnlyVInjectionsToAButton = ButtonWidget.builder(this.debugBooleanLabel(this.convertMaleOnlyVInjectionsToA), button -> { + this.convertMaleOnlyVInjectionsToA = !this.convertMaleOnlyVInjectionsToA; + button.setMessage(this.debugBooleanLabel(this.convertMaleOnlyVInjectionsToA)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetConvertMaleOnlyVInjectionsToAButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.convertMaleOnlyVInjectionsToA = this.defaults.convertMaleOnlyVInjectionsToA(); + this.convertMaleOnlyVInjectionsToAButton.setMessage(this.debugBooleanLabel(this.convertMaleOnlyVInjectionsToA)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetConvertMaleOnlyVInjectionsToAButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.convert_male_v_to_a"), + this.convertMaleOnlyVInjectionsToAButton, + this.resetConvertMaleOnlyVInjectionsToAButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.convert_male_v_to_a") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.animation_runtime"))); + this.forceVanillaEntityTexturesButton = ButtonWidget.builder(this.onOffLabel(this.forceVanillaEntityTextures), button -> { + this.forceVanillaEntityTextures = !this.forceVanillaEntityTextures; + button.setMessage(this.onOffLabel(this.forceVanillaEntityTextures)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetForceVanillaEntityTexturesButton = this.resetButton(button -> { + this.forceVanillaEntityTextures = this.defaults.forceVanillaEntityTextures(); + this.forceVanillaEntityTexturesButton.setMessage(this.onOffLabel(this.forceVanillaEntityTextures)); + this.updateResetButtons(); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.animation.force_vanilla_entity_textures"), + this.forceVanillaEntityTexturesButton, + this.resetForceVanillaEntityTexturesButton, + null + ) + ); + this.animationBlockSearchRadiusButton = ButtonWidget.builder(Text.literal(String.valueOf(this.animationBlockSearchRadius)), button -> { + this.animationBlockSearchRadius = this.animationBlockSearchRadius >= 16 ? 1 : this.animationBlockSearchRadius + 1; + button.setMessage(Text.literal(String.valueOf(this.animationBlockSearchRadius))); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetAnimationBlockSearchRadiusButton = this.resetButton(button -> { + this.animationBlockSearchRadius = this.defaults.blockSearchRadius(); + this.animationBlockSearchRadiusButton.setMessage(Text.literal(String.valueOf(this.animationBlockSearchRadius))); + this.updateResetButtons(); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.animation.block_search_radius"), + this.animationBlockSearchRadiusButton, + this.resetAnimationBlockSearchRadiusButton, + null + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.animation_data"))); + ButtonWidget loadedAnimationsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.loaded_animations"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.LoadedAnimationsScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(loadedAnimationsButton, "config.needsofnature.tooltip.loaded_animations"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, loadedAnimationsButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.loaded_animations") + ) + ); + ButtonWidget loadOrderButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.loaded_animations.load_order"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.LoadOrderScreen(this, this.localConfig)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(loadOrderButton, "config.needsofnature.tooltip.loaded_animations.load_order"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, loadOrderButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.loaded_animations.load_order") + ) + ); + this.budgetField.setChangedListener(ignored -> this.updateResetButtons()); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.scanBudget = this.clampBudget(this.parseField(this.budgetField, this.scanBudget)); + this.config.setScanBudgetPerTick(this.scanBudget); + this.config.setConvertMaleOnlyVInjectionsToA(this.convertMaleOnlyVInjectionsToA); + this.config.setBlockSearchRadius(this.animationBlockSearchRadius); + NonModMenuScreens.saveServerConfig(this.config); + } + + this.localConfig.setDebugChatMode(this.debugChatMode); + this.localConfig.setDebugSpinMode(this.debugSpinMode); + this.localConfig.setForceVanillaEntityTextures(this.forceVanillaEntityTextures); + this.localConfig.save(); + if (this.forceVanillaEntityTextures != this.initialForceVanillaEntityTextures) { + VanillaTextureResolver.clearCache(); + } + + AnimationEngineClient.sendDebugChatPreference(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int clampBudget(int v) { + return Math.max(1, Math.min(512, v)); + } + + private Text debugChatLabel(NonDebugChatMode mode) { + NonDebugChatMode resolved = mode == null ? NonDebugChatMode.SETUP_ERRORS : mode; + return Text.translatable("config.needsofnature.debug_chat_mode." + resolved.id()); + } + + private NonDebugChatMode nextDebugChatMode(NonDebugChatMode current) { + if (current == null) { + return NonDebugChatMode.SETUP_ERRORS; + } + + return switch (current) { + case ALL -> NonDebugChatMode.SETUP_WARNINGS_ERRORS; + case SETUP_WARNINGS_ERRORS -> NonDebugChatMode.SETUP_ERRORS; + case SETUP_ERRORS -> NonDebugChatMode.ERRORS_ONLY; + case ERRORS_ONLY -> NonDebugChatMode.ALL; + }; + } + + private Text debugSpinModeLabel(boolean enabled) { + return Text.translatable("config.needsofnature.debug_spin_mode.value", new Object[]{Text.translatable(enabled ? "options.on" : "options.off")}); + } + + private Text debugBooleanLabel(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private Text onOffLabel(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private ButtonWidget resetButton(PressAction action) { + ButtonWidget button = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), action).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(button, "config.needsofnature.tooltip.reset"); + return button; + } + + private void updateResetButtons() { + if (this.resetBudgetButton != null) { + this.resetBudgetButton.active = this.serverConfigEditable + && this.parseField(this.budgetField, this.defaults.getScanBudgetPerTick()) != this.defaults.getScanBudgetPerTick(); + } + + if (this.resetDebugModeButton != null) { + this.resetDebugModeButton.active = this.debugChatMode != this.defaults.debugChatMode(); + } + + if (this.resetDebugSpinModeButton != null) { + this.resetDebugSpinModeButton.active = this.debugSpinMode != this.defaults.isDebugSpinMode(); + } + + if (this.resetConvertMaleOnlyVInjectionsToAButton != null) { + this.resetConvertMaleOnlyVInjectionsToAButton.active = this.serverConfigEditable + && this.convertMaleOnlyVInjectionsToA != this.defaults.convertMaleOnlyVInjectionsToA(); + } + + if (this.resetForceVanillaEntityTexturesButton != null) { + this.resetForceVanillaEntityTexturesButton.active = this.forceVanillaEntityTextures != this.defaults.forceVanillaEntityTextures(); + } + + if (this.resetAnimationBlockSearchRadiusButton != null) { + this.resetAnimationBlockSearchRadiusButton.active = this.serverConfigEditable + && this.animationBlockSearchRadius != this.defaults.blockSearchRadius(); + } + + if (this.animationBlockSearchRadiusButton != null) { + this.animationBlockSearchRadiusButton.active = this.serverConfigEditable; + } + + if (this.convertMaleOnlyVInjectionsToAButton != null) { + this.convertMaleOnlyVInjectionsToAButton.active = this.serverConfigEditable; + } + } + } + + static class GenderSpawnConfigScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private boolean serverConfigEditable; + private Map appliedMap; + private NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList genderList; + private final List rows = new ArrayList<>(); + private ButtonWidget applyButton; + private ButtonWidget doneButton; + + protected GenderSpawnConfigScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.gender_spawn_title")); + this.parent = parent; + this.config = config; + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + this.appliedMap = new LinkedHashMap<>(NonEntityProfiles.resolveEffectiveGenderSpawnMap()); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + List preserved = this.snapshotRows(); + if (preserved == null) { + preserved = new ArrayList<>(); + + for (Entry entry : this.appliedMap.entrySet()) { + NonConfig.GenderSpawnChances chances = entry.getValue(); + if (chances != null) { + preserved.add( + new NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderRowData( + entry.getKey(), String.valueOf(chances.male()), String.valueOf(chances.female()), String.valueOf(chances.both()) + ) + ); + } + } + } + + int listTop = 44; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + this.genderList = new NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(this.genderList); + this.rows.clear(); + + for (NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderRowData data : preserved) { + this.addRow(data.id(), data.male(), data.female(), data.both()); + } + + if (this.rows.isEmpty()) { + this.addRow("", "48", "47", "5"); + } + + int centerX = this.width / 2; + ButtonWidget addButton = ButtonWidget.builder(Text.translatable("config.needsofnature.gender_spawn_add"), button -> { + this.addRow("", "48", "47", "5"); + this.updateApplyButton(); + }).dimensions(centerX - 100, this.height - 52, 96, 20).build(); + addButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(addButton, "config.needsofnature.tooltip.gender_spawn_add"); + this.addDrawableChild(addButton); + ButtonWidget resetDefaultsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.gender_spawn_reset"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.GenderSpawnConfigScreen.ResetGenderSpawnConfirmScreen(this, () -> { + this.applyDefaultsToRows(); + this.updateApplyButton(); + MinecraftClient.getInstance().setScreen(this); + })) + ) + .dimensions(centerX + 4, this.height - 52, 96, 20) + .build(); + resetDefaultsButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(resetDefaultsButton, "config.needsofnature.tooltip.gender_spawn_reset"); + this.addDrawableChild(resetDefaultsButton); + this.applyButton = ButtonWidget.builder(Text.translatable("config.needsofnature.apply"), button -> { + if (this.serverConfigEditable) { + if (this.hasInvalidGenderChanceRows()) { + this.updateApplyButton(); + } else { + Map next = this.collectGenderMapFromFields(); + this.config.setEntityProfilesByEntity(this.mergeGenderSpawnIntoEntityProfiles(next)); + NonModMenuScreens.saveServerConfig(this.config); + this.appliedMap = new LinkedHashMap<>(next); + this.updateApplyButton(); + } + } + }).dimensions(centerX - 100, this.height - 28, 96, 20).build(); + NonModMenuScreens.setTooltip(this.applyButton, "config.needsofnature.tooltip.apply"); + this.addDrawableChild(this.applyButton); + this.doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, this.height - 28, 96, 20) + .build(); + NonModMenuScreens.setTooltip(this.doneButton, "config.needsofnature.tooltip.done_unsaved"); + this.addDrawableChild(this.doneButton); + this.updateApplyButton(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateApplyButton(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + this.drawGenderSpawnHeader(context); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void drawGenderSpawnHeader(DrawContext context) { + if (this.genderList != null) { + int rowX = this.genderList.getRowLeft(); + int rowWidth = this.genderList.getRowWidth(); + int fieldW = 42; + int removeW = 20; + int idW = Math.max(130, rowWidth - removeW - fieldW * 3 - 30); + int maleX = rowX + idW + 6; + int femaleX = maleX + fieldW + 6; + int bothX = femaleX + fieldW + 6; + int y = 34; + int color = -4342339; + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.gender_spawn_column.entity"), rowX + 4, y, color); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.gender_spawn_column.male"), maleX + fieldW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.gender_spawn_column.female"), femaleX + fieldW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.gender_spawn_column.both"), bothX + fieldW / 2, y, color + ); + } + } + + private void addRow(String idText, String maleText, String femaleText, String bothText) { + NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry[] holder = new NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry[1]; + NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry entry = new NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry( + this.textRenderer, idText, maleText, femaleText, bothText, () -> { + NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry current = holder[0]; + if (current != null) { + this.rows.removeIf(row -> row == current); + this.genderList.removeEntryRow(current); + this.updateApplyButton(); + } + } + ); + holder[0] = entry; + entry.setOnChange(this::updateApplyButton); + entry.setEditable(this.serverConfigEditable); + this.rows.add(entry); + this.genderList.addEntryRow(entry); + } + + private void updateApplyButton() { + if (this.applyButton != null) { + if (!this.serverConfigEditable) { + this.applyButton.active = false; + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable("config.needsofnature.done")); + } + } else { + Map current = this.collectGenderMapFromFields(); + boolean hasUnsavedChanges = !current.equals(this.appliedMap); + boolean hasInvalidRows = this.hasInvalidGenderChanceRows(); + this.applyButton.active = hasUnsavedChanges && !hasInvalidRows; + this.applyButton + .setMessage(Text.translatable(hasInvalidRows ? "config.needsofnature.gender_spawn_invalid_total" : "config.needsofnature.apply")); + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable(hasUnsavedChanges ? "config.needsofnature.done_unsaved" : "config.needsofnature.done")); + } + } + } + } + + private boolean hasInvalidGenderChanceRows() { + for (NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry row : this.rows) { + if (row != null) { + String id = row.getIdText(); + if (id != null && !id.trim().isEmpty()) { + int total = this.parseInt(row.getMaleText()) + this.parseInt(row.getFemaleText()) + this.parseInt(row.getBothText()); + if (total != 100) { + return true; + } + } + } + } + + return false; + } + + private void applyDefaultsToRows() { + Map defaultsMap = new LinkedHashMap<>(); + + for (Entry entry : NonEntityProfiles.resolveDefaultProfilesMap().entrySet()) { + NonConfig.GenderSpawnChances chances = entry.getValue().genderSpawn(); + if (chances != null) { + defaultsMap.put(entry.getKey(), chances); + } + } + + if (this.genderList != null) { + for (NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry entry : new ArrayList<>(this.rows)) { + this.genderList.removeEntryRow(entry); + } + } + + this.rows.clear(); + + for (Entry entry : defaultsMap.entrySet()) { + NonConfig.GenderSpawnChances chances = entry.getValue(); + if (chances != null) { + this.addRow(entry.getKey(), String.valueOf(chances.male()), String.valueOf(chances.female()), String.valueOf(chances.both())); + } + } + + if (this.rows.isEmpty()) { + this.addRow("", "48", "47", "5"); + } + } + + private Map mergeGenderSpawnIntoEntityProfiles(Map genderMap) { + Map profiles = new LinkedHashMap<>(); + if (genderMap != null) { + for (Entry entry : genderMap.entrySet()) { + Identifier id = Identifier.tryParse(entry.getKey()); + if (id != null && entry.getValue() != null) { + NonConfig.EntityProfile base = this.config.getEntityProfilesByEntity().get(entry.getKey()); + NonConfig.GenderSpawnChances defaultGender = NonEntityProfiles.resolveDefaultProfile(id).genderSpawn(); + NonConfig.GenderSpawnChances savedGender = entry.getValue().equals(defaultGender) ? null : entry.getValue(); + NonConfig.EntityProfile next = new NonConfig.EntityProfile( + base == null ? null : base.pregnancyChancePercent(), + base == null ? null : base.offspringMin(), + base == null ? null : base.offspringMax(), + base == null ? null : base.birthEntity(), + base == null ? null : base.birthMode(), + savedGender, + base == null ? null : base.egg(), + base == null ? null : base.lookAtNearbyAnimations(), + base == null ? null : base.multiActorJoinChancePercent(), + base == null ? null : base.energyGainMultiplier() + ) + .sanitized(); + if (!next.isEmpty()) { + profiles.put(entry.getKey(), next); + } + } + } + } + + return profiles; + } + + private Map collectGenderMapFromFields() { + Map map = new LinkedHashMap<>(); + + for (NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry row : this.rows) { + if (row != null) { + String id = row.getIdText(); + if (id != null) { + id = id.trim(); + if (!id.isEmpty() && Identifier.tryParse(id) != null) { + NonConfig.GenderSpawnChances chances = NonConfig.sanitizeGenderSpawnChances( + new NonConfig.GenderSpawnChances(this.parseInt(row.getMaleText()), this.parseInt(row.getFemaleText()), this.parseInt(row.getBothText())) + ); + if (chances != null) { + map.put(id, chances); + } + } + } + } + } + + return map; + } + + private List snapshotRows() { + if (this.rows.isEmpty()) { + return null; + } + + List data = new ArrayList<>(); + + for (NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry row : this.rows) { + data.add( + new NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderRowData(row.getIdText(), row.getMaleText(), row.getFemaleText(), row.getBothText()) + ); + } + + return data; + } + + private int parseInt(String value) { + if (value == null) { + return 0; + } + + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + private record GenderRowData(String id, String male, String female, String both) { + } + + private static class GenderSpawnList extends ElementListWidget { + private GenderSpawnList(MinecraftClient client, int width, int height, int top) { + super(client, width, height, top, 24); + this.centerListVertically = false; + } + + private void addEntryRow(NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry entry) { + super.addEntry(entry); + } + + private void removeEntryRow(NonModMenuDebugScreens.GenderSpawnConfigScreen.GenderSpawnList.GenderEntry entry) { + super.removeEntry(entry); + } + + public int getRowWidth() { + return 420; + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + private static final class GenderEntry + extends net.minecraft.client.gui.widget.ElementListWidget.Entry { + private final TextFieldWidget idField; + private final TextFieldWidget maleField; + private final TextFieldWidget femaleField; + private final TextFieldWidget bothField; + private final ButtonWidget removeButton; + private final List widgets; + private Runnable onChange; + + private GenderEntry(TextRenderer textRenderer, String idText, String maleText, String femaleText, String bothText, Runnable onRemove) { + this.idField = new TextFieldWidget(textRenderer, 0, 0, 170, 20, Text.literal("")); + this.idField.setText(idText == null ? "" : idText); + this.idField.setMaxLength(80); + NonModMenuScreens.setTooltip(this.idField, "config.needsofnature.tooltip.gender_spawn_entry_entity"); + this.maleField = percentField(textRenderer, maleText); + NonModMenuScreens.setTooltip(this.maleField, "config.needsofnature.tooltip.gender_spawn_entry_male"); + this.femaleField = percentField(textRenderer, femaleText); + NonModMenuScreens.setTooltip(this.femaleField, "config.needsofnature.tooltip.gender_spawn_entry_female"); + this.bothField = percentField(textRenderer, bothText); + NonModMenuScreens.setTooltip(this.bothField, "config.needsofnature.tooltip.gender_spawn_entry_both"); + this.removeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.remove_icon"), button -> { + if (onRemove != null) { + onRemove.run(); + } + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(this.removeButton, "config.needsofnature.tooltip.remove"); + this.widgets = new ArrayList<>(5); + this.widgets.add(this.idField); + this.widgets.add(this.maleField); + this.widgets.add(this.femaleField); + this.widgets.add(this.bothField); + this.widgets.add(this.removeButton); + this.idField.setChangedListener(ignored -> this.notifyChanged()); + this.maleField.setChangedListener(ignored -> this.notifyChanged()); + this.femaleField.setChangedListener(ignored -> this.notifyChanged()); + this.bothField.setChangedListener(ignored -> this.notifyChanged()); + } + + private static TextFieldWidget percentField(TextRenderer textRenderer, String text) { + TextFieldWidget field = new TextFieldWidget(textRenderer, 0, 0, 42, 20, Text.literal("")); + field.setText(text == null ? "0" : text); + field.setMaxLength(3); + return field; + } + + private void setOnChange(Runnable onChange) { + this.onChange = onChange; + } + + private void notifyChanged() { + if (this.onChange != null) { + this.onChange.run(); + } + } + + private void setEditable(boolean editable) { + this.idField.setEditable(editable); + this.maleField.setEditable(editable); + this.femaleField.setEditable(editable); + this.bothField.setEditable(editable); + this.removeButton.active = editable; + } + + private String getIdText() { + return this.idField.getText(); + } + + private String getMaleText() { + return this.maleField.getText(); + } + + private String getFemaleText() { + return this.femaleField.getText(); + } + + private String getBothText() { + return this.bothField.getText(); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int rowX = this.getContentX(); + int rowY = this.getContentY(); + int rowWidth = this.getContentWidth(); + int widgetY = rowY + 2; + int removeW = this.removeButton.getWidth(); + int fieldW = this.maleField.getWidth(); + int idW = Math.max(130, rowWidth - removeW - fieldW * 3 - 30); + int maleX = rowX + idW + 6; + int femaleX = maleX + fieldW + 6; + int bothX = femaleX + fieldW + 6; + int removeX = bothX + fieldW + 6; + this.idField.setX(rowX); + this.idField.setY(widgetY); + this.idField.setWidth(idW); + this.maleField.setX(maleX); + this.maleField.setY(widgetY); + this.femaleField.setX(femaleX); + this.femaleField.setY(widgetY); + this.bothField.setX(bothX); + this.bothField.setY(widgetY); + this.removeButton.setX(removeX); + this.removeButton.setY(widgetY); + + for (ClickableWidget widget : this.widgets) { + widget.render(context, mouseX, mouseY, deltaTicks); + } + } + + public List selectableChildren() { + return this.widgets; + } + + public List children() { + return this.widgets; + } + } + } + + private static final class ResetGenderSpawnConfirmScreen extends Screen { + private final Screen parent; + private final Runnable onConfirm; + + private ResetGenderSpawnConfirmScreen(Screen parent, Runnable onConfirm) { + super(Text.translatable("config.needsofnature.gender_spawn_reset_title")); + this.parent = parent; + this.onConfirm = onConfirm; + } + + protected void init() { + int centerX = this.width / 2; + int buttonY = this.height / 2 + 20; + ButtonWidget yesButton = ButtonWidget.builder(Text.translatable("gui.yes"), button -> { + if (this.onConfirm != null) { + this.onConfirm.run(); + } + + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX - 100, buttonY, 96, 20).build(); + this.addDrawableChild(yesButton); + ButtonWidget noButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, buttonY, 96, 20) + .build(); + this.addDrawableChild(noButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.height / 2 - 20, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.gender_spawn_reset_body"), this.width / 2, this.height / 2 - 4, -3355444 + ); + } + } + } + + static class LiquidGainConfigScreen extends Screen { + private static final String MIXED_COLOR_ROW_ID = "needsofnature:mixed"; + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean serverConfigEditable; + private Map appliedMap; + private Map appliedColorMap; + private NonModMenuDebugScreens.LiquidGainConfigScreen.GainList gainList; + private final List rows = new ArrayList<>(); + private ButtonWidget applyButton; + private ButtonWidget doneButton; + + protected LiquidGainConfigScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.liquid_gain_title")); + this.parent = parent; + this.config = config; + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + this.appliedMap = new LinkedHashMap<>(NonLiquidGainOverrides.getGainByEntity()); + this.appliedMap.putAll(config.getLiquidGainByEntity()); + this.appliedColorMap = new LinkedHashMap<>(NonLiquidGainOverrides.getColorByEntity()); + this.appliedColorMap.putAll(config.getLiquidColorByEntity()); + this.appliedMap.remove("needsofnature:mixed"); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + List preserved = this.snapshotRows(); + if (preserved == null) { + preserved = new ArrayList<>(); + preserved.add( + new NonModMenuDebugScreens.LiquidGainConfigScreen.RowData( + "needsofnature:mixed", "0", this.appliedColorMap.getOrDefault("needsofnature:mixed", resolveDefaultMixedColorHex()) + ) + ); + LinkedHashSet ids = new LinkedHashSet<>(); + ids.addAll(this.appliedMap.keySet()); + ids.addAll(this.appliedColorMap.keySet()); + ids.remove("needsofnature:mixed"); + + for (String id : ids) { + int gain = this.appliedMap.getOrDefault(id, 0); + String color = this.appliedColorMap.getOrDefault(id, ""); + preserved.add(new NonModMenuDebugScreens.LiquidGainConfigScreen.RowData(id, String.valueOf(gain), color)); + } + } + + preserved = this.ensureMixedRowPresent(preserved); + int listTop = 44; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + this.gainList = new NonModMenuDebugScreens.LiquidGainConfigScreen.GainList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(this.gainList); + this.rows.clear(); + + for (NonModMenuDebugScreens.LiquidGainConfigScreen.RowData data : preserved) { + this.addRow(data.id(), data.value(), data.colorHex()); + } + + if (this.rows.isEmpty()) { + this.addRow("", "0", ""); + } + + int centerX = this.width / 2; + ButtonWidget addButton = ButtonWidget.builder(Text.translatable("config.needsofnature.liquid_gain_add"), button -> { + this.addRow("", "0", ""); + this.updateApplyButton(); + }).dimensions(centerX - 100, this.height - 52, 96, 20).build(); + addButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(addButton, "config.needsofnature.tooltip.liquid_gain_add"); + this.addDrawableChild(addButton); + ButtonWidget resetDefaultsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.liquid_gain_reset"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.LiquidGainConfigScreen.ResetLiquidGainConfirmScreen(this, () -> { + this.applyDefaultsToRows(); + this.updateApplyButton(); + MinecraftClient.getInstance().setScreen(this); + })) + ) + .dimensions(centerX + 4, this.height - 52, 96, 20) + .build(); + resetDefaultsButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(resetDefaultsButton, "config.needsofnature.tooltip.liquid_gain_reset"); + this.addDrawableChild(resetDefaultsButton); + this.applyButton = ButtonWidget.builder(Text.translatable("config.needsofnature.apply"), button -> { + if (this.serverConfigEditable) { + Map nextGain = this.collectGainMapFromFields(); + Map nextColor = this.collectColorMapFromFields(); + this.config.setLiquidGainByEntity(nextGain); + this.config.setLiquidColorByEntity(nextColor); + NonModMenuScreens.saveServerConfig(this.config); + this.appliedMap = new LinkedHashMap<>(nextGain); + this.appliedColorMap = new LinkedHashMap<>(nextColor); + this.updateApplyButton(); + } + }).dimensions(centerX - 100, this.height - 28, 96, 20).build(); + NonModMenuScreens.setTooltip(this.applyButton, "config.needsofnature.tooltip.apply"); + this.addDrawableChild(this.applyButton); + this.doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, this.height - 28, 96, 20) + .build(); + NonModMenuScreens.setTooltip(this.doneButton, "config.needsofnature.tooltip.done_unsaved"); + this.addDrawableChild(this.doneButton); + this.updateApplyButton(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateApplyButton(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + this.drawLiquidGainHeader(context); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void drawLiquidGainHeader(DrawContext context) { + if (this.gainList != null) { + int rowX = this.gainList.getRowLeft(); + int rowWidth = this.gainList.getRowWidth(); + int removeW = 20; + int valueW = 50; + int colorW = 80; + int idW = Math.max(120, rowWidth - removeW - valueW - colorW - 18); + int valueX = rowX + idW + 6; + int colorX = valueX + valueW + 6; + int y = 34; + int color = -4342339; + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.liquid_gain_column.entity"), rowX + 4, y, color); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.liquid_gain_column.ml"), valueX + valueW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.liquid_gain_column.color"), colorX + colorW / 2, y, color + ); + } + } + + private void addRow(String idText, String valueText, String colorHexText) { + NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry[] holder = new NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry[1]; + NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry entry = new NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry( + this.textRenderer, idText, valueText, colorHexText, () -> { + NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry current = holder[0]; + if (current != null) { + this.rows.removeIf(row -> row == current); + this.gainList.removeEntryRow(current); + this.updateApplyButton(); + } + } + ); + holder[0] = entry; + entry.setOnChange(this::updateApplyButton); + entry.setEditable(this.serverConfigEditable); + if (isMixedRowId(idText)) { + entry.setMixedRowMode(this.serverConfigEditable, "needsofnature:mixed"); + } + + this.rows.add(entry); + this.gainList.addEntryRow(entry); + } + + private void updateApplyButton() { + if (this.applyButton != null) { + if (!this.serverConfigEditable) { + this.applyButton.active = false; + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable("config.needsofnature.done")); + } + } else { + Map currentGain = this.collectGainMapFromFields(); + Map currentColor = this.collectColorMapFromFields(); + boolean hasUnsavedChanges = !currentGain.equals(this.appliedMap) || !currentColor.equals(this.appliedColorMap); + this.applyButton.active = hasUnsavedChanges; + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable(hasUnsavedChanges ? "config.needsofnature.done_unsaved" : "config.needsofnature.done")); + } + } + } + } + + private void applyDefaultsToRows() { + Map defaultsMap = new LinkedHashMap<>(this.defaults.getLiquidGainByEntity()); + defaultsMap.putAll(NonLiquidGainOverrides.getGainByEntity()); + defaultsMap.remove("needsofnature:mixed"); + Map defaultColors = new LinkedHashMap<>(this.defaults.getLiquidColorByEntity()); + defaultColors.putAll(NonLiquidGainOverrides.getColorByEntity()); + defaultColors.remove("needsofnature:mixed"); + if (this.gainList != null) { + for (NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry entry : new ArrayList<>(this.rows)) { + this.gainList.removeEntryRow(entry); + } + } + + this.rows.clear(); + this.addRow("needsofnature:mixed", "0", resolveDefaultMixedColorHex()); + LinkedHashSet ids = new LinkedHashSet<>(); + ids.addAll(defaultsMap.keySet()); + ids.addAll(defaultColors.keySet()); + + for (String id : ids) { + int gain = defaultsMap.getOrDefault(id, 0); + String color = defaultColors.getOrDefault(id, ""); + this.addRow(id, String.valueOf(gain), color); + } + + if (this.rows.isEmpty()) { + this.addRow("", "0", ""); + } + } + + private Map collectGainMapFromFields() { + Map map = new LinkedHashMap<>(); + + for (NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry row : this.rows) { + if (row != null) { + String id = row.getIdText(); + if (id != null) { + id = id.trim(); + if (!id.isEmpty() && !isMixedRowId(id)) { + int value = this.parseInt(row.getValueText()); + value = Math.max(0, Math.min(1000, value)); + map.put(id, value); + } + } + } + } + + return map; + } + + private Map collectColorMapFromFields() { + Map map = new LinkedHashMap<>(); + + for (NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry row : this.rows) { + if (row != null) { + String id = row.getIdText(); + if (id != null) { + id = id.trim(); + if (!id.isEmpty()) { + String normalized = NonLiquidColors.normalizeHexColor(row.getColorHexText()); + if (normalized != null) { + map.put(id, normalized); + } + } + } + } + } + + return map; + } + + private List snapshotRows() { + if (this.rows.isEmpty()) { + return null; + } + + List data = new ArrayList<>(); + + for (NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry row : this.rows) { + data.add(new NonModMenuDebugScreens.LiquidGainConfigScreen.RowData(row.getIdText(), row.getValueText(), row.getColorHexText())); + } + + return data; + } + + private List ensureMixedRowPresent( + List source + ) { + List out = new ArrayList<>(); + String mixedColor = resolveDefaultMixedColorHex(); + if (source != null) { + for (NonModMenuDebugScreens.LiquidGainConfigScreen.RowData row : source) { + if (row != null) { + if (isMixedRowId(row.id())) { + String candidate = NonLiquidColors.normalizeHexColor(row.colorHex()); + if (candidate != null) { + mixedColor = candidate; + } + } else { + out.add(row); + } + } + } + } + + out.add(0, new NonModMenuDebugScreens.LiquidGainConfigScreen.RowData("needsofnature:mixed", "0", mixedColor)); + return out; + } + + private static boolean isMixedRowId(String id) { + return id == null ? false : "needsofnature:mixed".equals(id.trim()); + } + + private static String resolveDefaultMixedColorHex() { + String packHex = NonLiquidGainOverrides.getMixedColorHex(); + return packHex != null ? packHex : String.format(Locale.ROOT, "%06X", 15920063); + } + + private int parseInt(String value) { + if (value == null) { + return 0; + } + + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + private static final class GainList extends ElementListWidget { + private GainList(MinecraftClient client, int width, int height, int top) { + super(client, width, height, top, 24); + this.centerListVertically = false; + } + + private void addEntryRow(NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry entry) { + super.addEntry(entry); + } + + private void removeEntryRow(NonModMenuDebugScreens.LiquidGainConfigScreen.GainList.GainEntry entry) { + super.removeEntry(entry); + } + + public int getRowWidth() { + return 360; + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + private static final class GainEntry + extends net.minecraft.client.gui.widget.ElementListWidget.Entry { + private final TextFieldWidget idField; + private final TextFieldWidget valueField; + private final TextFieldWidget colorHexField; + private final ButtonWidget removeButton; + private final List widgets; + private Runnable onChange; + + private GainEntry(TextRenderer textRenderer, String idText, String valueText, String colorHexText, Runnable onRemove) { + this.idField = new TextFieldWidget(textRenderer, 0, 0, 160, 20, Text.literal("")); + this.idField.setText(idText == null ? "" : idText); + this.idField.setMaxLength(80); + NonModMenuScreens.setTooltip(this.idField, "config.needsofnature.tooltip.liquid_gain_entry_entity"); + this.valueField = new TextFieldWidget(textRenderer, 0, 0, 50, 20, Text.literal("")); + this.valueField.setText(valueText == null ? "0" : valueText); + this.valueField.setMaxLength(6); + NonModMenuScreens.setTooltip(this.valueField, "config.needsofnature.tooltip.liquid_gain_entry_ml"); + this.colorHexField = new TextFieldWidget(textRenderer, 0, 0, 80, 20, Text.literal("")); + this.colorHexField.setText(colorHexText == null ? "" : colorHexText); + this.colorHexField.setPlaceholder(Text.literal("#RRGGBB")); + this.colorHexField.setMaxLength(7); + NonModMenuScreens.setTooltip(this.colorHexField, "config.needsofnature.tooltip.liquid_gain_entry_color"); + this.removeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.remove_icon"), button -> { + if (onRemove != null) { + onRemove.run(); + } + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(this.removeButton, "config.needsofnature.tooltip.remove"); + this.widgets = new ArrayList<>(4); + this.widgets.add(this.idField); + this.widgets.add(this.valueField); + this.widgets.add(this.colorHexField); + this.widgets.add(this.removeButton); + this.idField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.valueField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.colorHexField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + } + + private void setOnChange(Runnable onChange) { + this.onChange = onChange; + } + + private void setEditable(boolean editable) { + this.idField.setEditable(editable); + this.valueField.setEditable(editable); + this.colorHexField.setEditable(editable); + this.removeButton.active = editable; + } + + private void setMixedRowMode(boolean editable, String mixedRowId) { + this.idField.setText(mixedRowId); + this.idField.setEditable(false); + this.valueField.setText("0"); + this.valueField.setEditable(false); + this.colorHexField.setEditable(editable); + this.removeButton.active = false; + } + + private String getIdText() { + return this.idField.getText(); + } + + private String getValueText() { + return this.valueField.getText(); + } + + private String getColorHexText() { + return this.colorHexField.getText(); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int rowX = this.getContentX(); + int rowY = this.getContentY(); + int rowWidth = this.getContentWidth(); + int widgetY = rowY + 2; + int removeW = this.removeButton.getWidth(); + int valueW = this.valueField.getWidth(); + int colorW = this.colorHexField.getWidth(); + int idW = Math.max(120, rowWidth - removeW - valueW - colorW - 18); + int valueX = rowX + idW + 6; + int colorX = valueX + valueW + 6; + int removeX = colorX + colorW + 6; + this.idField.setX(rowX); + this.idField.setY(widgetY); + this.idField.setWidth(idW); + this.valueField.setX(valueX); + this.valueField.setY(widgetY); + this.colorHexField.setX(colorX); + this.colorHexField.setY(widgetY); + this.removeButton.setX(removeX); + this.removeButton.setY(widgetY); + + for (ClickableWidget widget : this.widgets) { + widget.render(context, mouseX, mouseY, deltaTicks); + } + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + } + } + + private static final class ResetLiquidGainConfirmScreen extends Screen { + private final Screen parent; + private final Runnable onConfirm; + + private ResetLiquidGainConfirmScreen(Screen parent, Runnable onConfirm) { + super(Text.translatable("config.needsofnature.liquid_gain_reset_title")); + this.parent = parent; + this.onConfirm = onConfirm; + } + + protected void init() { + int centerX = this.width / 2; + int buttonY = this.height / 2 + 20; + ButtonWidget yesButton = ButtonWidget.builder(Text.translatable("gui.yes"), button -> { + if (this.onConfirm != null) { + this.onConfirm.run(); + } + + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX - 100, buttonY, 96, 20).build(); + this.addDrawableChild(yesButton); + ButtonWidget noButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, buttonY, 96, 20) + .build(); + this.addDrawableChild(noButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.height / 2 - 20, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.liquid_gain_reset_body"), this.width / 2, this.height / 2 - 4, -4144960 + ); + } + } + + private record RowData(String id, String value, String colorHex) { + } + } + + static class LoadOrderScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private boolean editable; + private final List externalPackOrder = new ArrayList<>(); + private List appliedExternalOrder = List.of(); + private List defaultExternalOrder = List.of(); + private NonModMenuDebugScreens.LoadOrderScreen.PackOrderList packOrderList; + private ButtonWidget resetButton; + + protected LoadOrderScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.loaded_animations.load_order_title")); + this.parent = parent; + this.config = config == null ? new NonConfig() : config; + } + + protected void init() { + this.editable = NonModMenuScreens.canEditLocalPackLoadOrder(); + this.reloadExternalOrder(); + int centerX = this.width / 2; + int listTop = 40; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + this.packOrderList = new NonModMenuDebugScreens.LoadOrderScreen.PackOrderList( + this.client, this.width, listHeight, listTop, this.textRenderer, this::moveUp, this::moveDown, this.editable + ); + this.addDrawableChild(this.packOrderList); + this.rebuildRows(); + this.resetButton = ButtonWidget.builder(Text.translatable("config.needsofnature.liquid_gain_reset"), button -> { + this.externalPackOrder.clear(); + this.externalPackOrder.addAll(this.defaultExternalOrder); + this.rebuildRows(); + this.updateButtons(); + }).dimensions(centerX - 100, this.height - 50, 98, 20).build(); + NonModMenuScreens.setTooltip(this.resetButton, "config.needsofnature.tooltip.loaded_animations.load_order.reset"); + this.addDrawableChild(this.resetButton); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> { + boolean changed = !this.externalPackOrder.equals(this.appliedExternalOrder); + if (this.editable) { + this.config.setExternalNoNPackLoadOrder(this.externalPackOrder); + this.config.save(); + this.appliedExternalOrder = List.copyOf(this.externalPackOrder); + if (changed) { + this.triggerInWorldReload(); + } + } + + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX + 2, this.height - 50, 98, 20).build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + this.updateButtons(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 12, -1); + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.loaded_animations.load_order.summary"), 8, 24, -5197648); + if (!this.editable) { + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.loaded_animations.load_order.read_only"), 8, 33, -14739); + } + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void reloadExternalOrder() { + List discovered = NonExternalPackProvider.listExternalPackFileNames(NonExternalPackProvider.resolveDefaultPacksDir()); + this.defaultExternalOrder = List.copyOf(discovered); + this.externalPackOrder.clear(); + this.externalPackOrder.addAll(NonExternalPackProvider.normalizeConfiguredOrder(this.config.getExternalNoNPackLoadOrder(), discovered)); + this.appliedExternalOrder = List.copyOf(this.externalPackOrder); + } + + private void triggerInWorldReload() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null) { + IntegratedServer server = client.getServer(); + if (server != null) { + server.execute(() -> { + try { + server.getCommandManager().getDispatcher().execute("reload", server.getCommandSource()); + } catch (Exception e) { + server.reloadResources(server.getDataPackManager().getEnabledIds()); + } + }); + } + + client.reloadResources(); + } + } + + private void moveUp(String key) { + this.moveBy(key, -1); + } + + private void moveDown(String key) { + this.moveBy(key, 1); + } + + private void moveBy(String key, int delta) { + if (this.editable && key != null && !key.isBlank()) { + int index = this.externalPackOrder.indexOf(key); + if (index >= 0) { + int target = index + delta; + if (target >= 0 && target < this.externalPackOrder.size()) { + String tmp = this.externalPackOrder.get(index); + this.externalPackOrder.set(index, this.externalPackOrder.get(target)); + this.externalPackOrder.set(target, tmp); + this.rebuildRows(); + this.updateButtons(); + } + } + } + } + + private void rebuildRows() { + if (this.packOrderList != null) { + List rows = new ArrayList<>(); + + for (int i = 0; i < this.externalPackOrder.size(); i++) { + String fileName = this.externalPackOrder.get(i); + rows.add(NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow.external(fileName, i, this.externalPackOrder.size())); + } + + rows.add( + NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow.locked( + "builtin:needsofnature", Text.translatable("config.needsofnature.loaded_animations.load_order.base_non") + ) + ); + this.packOrderList.setRows(rows); + } + } + + private void updateButtons() { + if (this.resetButton != null) { + this.resetButton.active = this.editable && !this.externalPackOrder.equals(this.defaultExternalOrder); + } + } + + private static final class PackOrderList extends ElementListWidget { + private static final int ROW_HEIGHT = 30; + private static final int MIN_ROW_WIDTH = 260; + private static final int MAX_ROW_WIDTH = 420; + private final TextRenderer textRenderer; + private final Consumer onMoveUp; + private final Consumer onMoveDown; + private final boolean editable; + private int preferredRowWidth = 260; + + private PackOrderList( + MinecraftClient client, + int width, + int height, + int top, + TextRenderer textRenderer, + Consumer onMoveUp, + Consumer onMoveDown, + boolean editable + ) { + super(client, width, height, top, 30); + this.textRenderer = textRenderer; + this.onMoveUp = onMoveUp; + this.onMoveDown = onMoveDown; + this.editable = editable; + this.centerListVertically = false; + } + + private void setRows(List rows) { + this.clearEntries(); + int widestContent = 260; + + for (NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow row : rows) { + int requiredWidth = this.textRenderer.getWidth(row.label()) + 28; + if (row.movable()) { + requiredWidth += 42; + } + + widestContent = Math.max(widestContent, requiredWidth); + this.addEntry( + new NonModMenuDebugScreens.LoadOrderScreen.PackOrderList.RowEntry(row, this.textRenderer, this.onMoveUp, this.onMoveDown, this.editable), 30 + ); + } + + this.preferredRowWidth = Math.min(420, widestContent); + } + + public int getRowWidth() { + return Math.min(this.preferredRowWidth, Math.max(120, this.getWidth() - 32)); + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + protected int getScrollbarX() { + return Math.min(this.getWidth() - 8, this.getRowLeft() + this.getRowWidth() + 6); + } + + private static final class OrderArrowButton extends ClickableWidget { + private static final int SIZE = 15; + private final boolean pointsUp; + private final Runnable onPress; + + private OrderArrowButton(boolean pointsUp, Runnable onPress) { + super( + 0, + 0, + 15, + 15, + Text.translatable( + pointsUp ? "config.needsofnature.loaded_animations.load_order.move_up" : "config.needsofnature.loaded_animations.load_order.move_down" + ) + ); + this.pointsUp = pointsUp; + this.onPress = onPress; + } + + protected void renderWidget(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + boolean highlighted = this.active && this.isSelected(); + int background = !this.active ? -2009055168 : (highlighted ? -12035736 : -583779260); + int border = !this.active ? -11513776 : (highlighted ? -7227697 : -10062464); + int arrow = !this.active ? -8947849 : (highlighted ? -1 : -2563609); + int left = this.getX(); + int top = this.getY(); + int right = left + this.getWidth(); + int bottom = top + this.getHeight(); + context.fill(left, top, right, bottom, background); + context.fill(left, top, right, top + 1, border); + context.fill(left, bottom - 1, right, bottom, border); + context.fill(left, top, left + 1, bottom, border); + context.fill(right - 1, top, right, bottom, border); + int centerX = left + this.getWidth() / 2; + int centerY = top + this.getHeight() / 2; + if (this.pointsUp) { + context.fill(centerX, centerY - 3, centerX + 1, centerY - 2, arrow); + context.fill(centerX - 1, centerY - 2, centerX + 2, centerY - 1, arrow); + context.fill(centerX - 2, centerY - 1, centerX + 3, centerY, arrow); + } else { + context.fill(centerX - 2, centerY, centerX + 3, centerY + 1, arrow); + context.fill(centerX - 1, centerY + 1, centerX + 2, centerY + 2, arrow); + context.fill(centerX, centerY + 2, centerX + 1, centerY + 3, arrow); + } + } + + public void onClick(Click click, boolean doubled) { + super.onClick(click, doubled); + if (this.active && this.onPress != null) { + this.onPress.run(); + } + } + + protected void appendClickableNarrations(NarrationMessageBuilder builder) { + this.appendDefaultNarrations(builder); + } + } + + private static final class RowEntry + extends net.minecraft.client.gui.widget.ElementListWidget.Entry { + private final NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow row; + private final TextRenderer textRenderer; + private final Consumer onMoveUp; + private final Consumer onMoveDown; + private final boolean editable; + private final NonModMenuDebugScreens.LoadOrderScreen.PackOrderList.OrderArrowButton upButton; + private final NonModMenuDebugScreens.LoadOrderScreen.PackOrderList.OrderArrowButton downButton; + private final List widgets; + + private RowEntry( + NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow row, + TextRenderer textRenderer, + Consumer onMoveUp, + Consumer onMoveDown, + boolean editable + ) { + this.row = row; + this.textRenderer = textRenderer; + this.onMoveUp = onMoveUp; + this.onMoveDown = onMoveDown; + this.editable = editable; + if (row.movable()) { + this.upButton = new NonModMenuDebugScreens.LoadOrderScreen.PackOrderList.OrderArrowButton(true, () -> { + if (this.onMoveUp != null) { + this.onMoveUp.accept(this.row.key()); + } + }); + this.downButton = new NonModMenuDebugScreens.LoadOrderScreen.PackOrderList.OrderArrowButton(false, () -> { + if (this.onMoveDown != null) { + this.onMoveDown.accept(this.row.key()); + } + }); + NonModMenuScreens.setTooltip(this.upButton, "config.needsofnature.tooltip.loaded_animations.load_order.move_up"); + NonModMenuScreens.setTooltip(this.downButton, "config.needsofnature.tooltip.loaded_animations.load_order.move_down"); + this.widgets = List.of(this.upButton, this.downButton); + } else { + this.upButton = null; + this.downButton = null; + this.widgets = List.of(); + } + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int cardLeft = this.getContentX() + 2; + int cardTop = this.getContentY() + 2; + int cardRight = this.getContentX() + this.getContentWidth() - 2; + int cardBottom = cardTop + 24; + int background = this.row.movable() ? (hovered ? -801490374 : -1339545560) : (hovered ? -802343867 : -1339939021); + int border = this.row.movable() ? -11513776 : -12365718; + context.fill(cardLeft, cardTop, cardRight, cardBottom, background); + context.fill(cardLeft, cardTop, cardRight, cardTop + 1, border); + context.fill(cardLeft, cardBottom - 1, cardRight, cardBottom, border); + context.fill(cardLeft, cardTop, cardLeft + 1, cardBottom, border); + context.fill(cardRight - 1, cardTop, cardRight, cardBottom, border); + int x = cardLeft + 8; + int y = cardTop + 8; + int right = cardRight - 6; + int textRight = this.row.movable() ? right - 40 : right; + int textWidth = Math.max(20, textRight - x); + int color = this.row.movable() ? -2565928 : -7362305; + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(this.row.label().getString(), textWidth), x, y, color); + if (this.upButton != null && this.downButton != null) { + this.upButton.active = this.editable && this.row.canMoveUp(); + this.downButton.active = this.editable && this.row.canMoveDown(); + this.upButton.setX(right - 32); + this.upButton.setY(cardTop + 4); + this.downButton.setX(right - 15); + this.downButton.setY(cardTop + 4); + this.upButton.render(context, mouseX, mouseY, deltaTicks); + this.downButton.render(context, mouseX, mouseY, deltaTicks); + } + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + } + } + + private record PackOrderRow(String key, Text label, boolean movable, boolean canMoveUp, boolean canMoveDown) { + private static NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow external(String fileName, int index, int total) { + String safeName = fileName != null && !fileName.isBlank() ? fileName : ""; + return new NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow( + safeName, Text.literal(index + 1 + ". " + safeName), true, index > 0, index < total - 1 + ); + } + + private static NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow locked(String key, Text label) { + String safeKey = key != null && !key.isBlank() ? key : ""; + Text safeLabel = (Text)(label == null ? Text.literal(safeKey) : label); + return new NonModMenuDebugScreens.LoadOrderScreen.PackOrderRow(safeKey, safeLabel, false, false, false); + } + } + } + + static class LoadedAnimationsScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final Set expandedRowKeys = new HashSet<>(); + private final Set expandedPackIds = new HashSet<>(); + private final Map sourceHintByDefinitionId = new LinkedHashMap<>(); + private TextFieldWidget filterField; + private ButtonWidget filterAllButton; + private ButtonWidget filterMissingButton; + private ButtonWidget filterPartialButton; + private ButtonWidget filterOkButton; + private ButtonWidget sortModeButton; + private ButtonWidget sortOrderButton; + private NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationsList loadedAnimationsList; + private List allRows = List.of(); + private final Map packIconByResourcePackId = new LinkedHashMap<>(); + private final Set disabledAnimationPacks = new LinkedHashSet<>(); + private final Set disabledAnimations = new LinkedHashSet<>(); + private int filteredCount = 0; + private boolean editable = true; + private NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter statusFilter = NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.ALL; + private NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode sortMode = NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode.ID; + private boolean sortAscending = true; + private long observedCatalogRevision = Long.MIN_VALUE; + private Text copyFeedbackText = Text.literal(""); + private long copyFeedbackUntilMs = 0L; + private static final long COPY_FEEDBACK_DURATION_MS = 1600L; + private static final int MISSING_ANIM_PREVIEW_COUNT = 3; + private static final int MISSING_MODEL_PREVIEW_COUNT = 3; + private static final int MAX_CONTENT_WIDTH = 500; + + protected LoadedAnimationsScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.loaded_animations_title")); + this.parent = parent; + this.config = config; + } + + protected void init() { + String previousFilter = this.filterField == null ? "" : this.filterField.getText(); + NonServerSettingsClient.requestRefresh(); + this.editable = NonModMenuScreens.canEditServerGameplaySettings(); + this.reloadDisabledAnimationState(); + int centerX = this.width / 2; + int filterY = 34; + int contentWidth = this.contentWidth(); + int contentLeft = (this.width - contentWidth) / 2; + int contentRight = contentLeft + contentWidth; + this.filterField = new TextFieldWidget( + this.textRenderer, contentLeft, filterY, contentWidth, 20, Text.translatable("config.needsofnature.loaded_animations_search") + ); + this.filterField.setMaxLength(128); + this.filterField.setText(previousFilter); + this.filterField.setChangedListener(this::applyFilter); + this.addDrawableChild(this.filterField); + int controlsY = filterY + 24; + int buttonH = 20; + int smallButtonW = 62; + int filterGap = 4; + int startX = contentLeft; + this.filterAllButton = ButtonWidget.builder(Text.literal("全部"), button -> { + this.statusFilter = NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.ALL; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText()); + }).dimensions(startX, controlsY, smallButtonW, buttonH).build(); + this.addDrawableChild(this.filterAllButton); + this.filterMissingButton = ButtonWidget.builder(Text.literal("缺失"), button -> { + this.statusFilter = NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.MISSING; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText()); + }).dimensions(startX + smallButtonW + filterGap, controlsY, smallButtonW, buttonH).build(); + this.addDrawableChild(this.filterMissingButton); + this.filterPartialButton = ButtonWidget.builder(Text.literal("部分"), button -> { + this.statusFilter = NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.PARTIAL; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText()); + }).dimensions(startX + (smallButtonW + filterGap) * 2, controlsY, smallButtonW, buttonH).build(); + this.addDrawableChild(this.filterPartialButton); + this.filterOkButton = ButtonWidget.builder(Text.literal("确定"), button -> { + this.statusFilter = NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.OK; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText()); + }).dimensions(startX + (smallButtonW + filterGap) * 3, controlsY, smallButtonW, buttonH).build(); + this.addDrawableChild(this.filterOkButton); + int orderButtonW = 56; + int sortButtonW = 116; + int orderX = contentRight - orderButtonW; + int sortX = orderX - 6 - sortButtonW; + this.sortModeButton = ButtonWidget.builder(this.sortModeLabel(), button -> { + this.sortMode = switch (this.sortMode) { + case ID -> NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode.STATUS; + case STATUS -> NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode.MISSING; + case MISSING -> NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode.ENTITIES; + case ENTITIES -> NonModMenuDebugScreens.LoadedAnimationsScreen.SortMode.ID; + }; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText(), false); + }).dimensions(sortX, controlsY, sortButtonW, buttonH).build(); + this.addDrawableChild(this.sortModeButton); + this.sortOrderButton = ButtonWidget.builder(this.sortOrderLabel(), button -> { + this.sortAscending = !this.sortAscending; + this.updateControlButtons(); + this.applyFilter(this.filterField.getText(), false); + }).dimensions(orderX, controlsY, orderButtonW, buttonH).build(); + this.addDrawableChild(this.sortOrderButton); + int listTop = controlsY + buttonH + 6; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + this.loadedAnimationsList = new NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationsList( + this.client, + this.width, + listHeight, + listTop, + this.textRenderer, + this::togglePackExpanded, + this::toggleRowExpanded, + this::copyRowMissingDetails, + this::togglePackEnabled, + this::toggleAnimationEnabled, + () -> this.editable + ); + this.addDrawableChild(this.loadedAnimationsList); + this.rebuildSourceHints(); + this.rebuildRows(); + this.updateControlButtons(); + this.applyFilter(previousFilter); + ButtonWidget doneButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent) + ) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 12, -1); + int contentWidth = this.contentWidth(); + int contentLeft = (this.width - contentWidth) / 2; + context.drawTextWithShadow( + this.textRenderer, + Text.translatable("config.needsofnature.loaded_animations_summary", new Object[]{this.allRows.size(), this.filteredCount}), + contentLeft, + 22, + -5197648 + ); + if (this.copyFeedbackText != null && !this.copyFeedbackText.getString().isBlank() && System.currentTimeMillis() < this.copyFeedbackUntilMs) { + int feedbackWidth = this.textRenderer.getWidth(this.copyFeedbackText); + int feedbackX = Math.max(contentLeft, contentLeft + contentWidth - feedbackWidth); + context.drawTextWithShadow(this.textRenderer, this.copyFeedbackText, feedbackX, 22, -6560902); + } + } + + private int contentWidth() { + return Math.min(500, Math.max(120, this.width - 16)); + } + + public void tick() { + super.tick(); + long revision = NonServerSettingsClient.catalogRevision(); + if (revision != this.observedCatalogRevision) { + this.observedCatalogRevision = revision; + this.reloadDisabledAnimationState(); + this.rebuildRows(); + this.applyFilter(this.filterField == null ? "" : this.filterField.getText(), false); + } + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void updateControlButtons() { + if (this.filterAllButton != null) { + this.filterAllButton.setMessage(this.statusFilterLabel(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.ALL)); + this.filterAllButton.active = this.statusFilter != NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.ALL; + } + + if (this.filterMissingButton != null) { + this.filterMissingButton.setMessage(this.statusFilterLabel(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.MISSING)); + this.filterMissingButton.active = this.statusFilter != NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.MISSING; + } + + if (this.filterPartialButton != null) { + this.filterPartialButton.setMessage(this.statusFilterLabel(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.PARTIAL)); + this.filterPartialButton.active = this.statusFilter != NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.PARTIAL; + } + + if (this.filterOkButton != null) { + this.filterOkButton.setMessage(this.statusFilterLabel(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.OK)); + this.filterOkButton.active = this.statusFilter != NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter.OK; + } + + if (this.sortModeButton != null) { + this.sortModeButton.setMessage(this.sortModeLabel()); + } + + if (this.sortOrderButton != null) { + this.sortOrderButton.setMessage(this.sortOrderLabel()); + } + } + + private Text statusFilterLabel(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter mode) { + boolean selected = this.statusFilter == mode; + + String base = switch (mode) { + case ALL -> "All"; + case MISSING -> "Missing"; + case PARTIAL -> "Partial"; + case OK -> "OK"; + }; + return Text.literal(selected ? "[" + base + "]" : base); + } + + private Text sortModeLabel() { + String value = switch (this.sortMode) { + case ID -> "ID"; + case STATUS -> "Status"; + case MISSING -> "Missing"; + case ENTITIES -> "Entities"; + }; + return Text.literal("Sort: " + value); + } + + private Text sortOrderLabel() { + return Text.literal(this.sortAscending ? "Asc" : "Desc"); + } + + private void rebuildSourceHints() { + this.sourceHintByDefinitionId.clear(); + ResourceManager manager = this.resolveResourceManagerForAnimdefs(); + if (manager != null) { + try { + Map> resources = manager.findAllResources("afw_animdefs", id -> id.getPath().endsWith(".json")); + + for (Entry> entry : resources.entrySet()) { + Identifier definitionId = definitionIdFromFile(entry.getKey()); + String sourceHint = summarizeSources(entry.getValue()); + this.sourceHintByDefinitionId.put(definitionId.toString(), sourceHint); + } + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to resolve animation definition source hints.", e); + } + } + } + + private ResourceManager resolveResourceManagerForAnimdefs() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return null; + } + + try { + if (client.getServer() != null) { + return client.getServer().getResourceManager(); + } + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to resolve server resource manager for loaded animations.", e); + } + + try { + return client.getResourceManager(); + } catch (RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to resolve client resource manager for loaded animations.", e); + return null; + } + } + + private static Identifier definitionIdFromFile(Identifier fileId) { + if (fileId == null) { + return AnimationEngine.packId("unknown"); + } + + String path = fileId.getPath(); + String prefix = "afw_animdefs/"; + if (path.startsWith(prefix)) { + path = path.substring(prefix.length()); + } + + if (path.endsWith(".json")) { + path = path.substring(0, path.length() - 5); + } + + return Identifier.of(fileId.getNamespace(), path); + } + + private static String summarizeSources(List resources) { + if (resources != null && !resources.isEmpty()) { + LinkedHashSet sources = new LinkedHashSet<>(); + + for (Resource resource : resources) { + if (resource != null) { + sources.add(normalizeSourceId(resource.getPackId())); + } + } + + if (sources.isEmpty()) { + return "unknown"; + } + + List list = new ArrayList<>(sources); + int take = Math.min(2, list.size()); + String summary = String.join(", ", list.subList(0, take)); + int more = list.size() - take; + if (more > 0) { + summary = summary + ", +" + more + " more"; + } + + return summary; + } else { + return "unknown"; + } + } + + private static String normalizeSourceId(String rawPackId) { + if (rawPackId == null || rawPackId.isBlank()) { + return "unknown"; + } + + if ("vanilla".equals(rawPackId)) { + return "builtin:vanilla"; + } + + if (!rawPackId.startsWith("needsofnature/")) { + return rawPackId; + } + + String value = rawPackId.substring("needsofnature/".length()); + int slash = value.lastIndexOf(47); + String tail = slash >= 0 && slash < value.length() - 1 ? value.substring(slash + 1) : value; + int underscore = tail.indexOf(95); + if (underscore > 0) { + boolean numericPrefix = true; + + for (int i = 0; i < underscore; i++) { + char c = tail.charAt(i); + if (c < '0' || c > '9') { + numericPrefix = false; + break; + } + } + + if (numericPrefix && underscore + 1 < tail.length()) { + tail = tail.substring(underscore + 1); + } + } + + return "nonpack:" + tail; + } + + private void reloadDisabledAnimationState() { + this.disabledAnimationPacks.clear(); + this.disabledAnimations.clear(); + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null && NonServerSettingsClient.hasAnimationCatalog()) { + for (AnimationCatalogData.Entry entry : NonServerSettingsClient.animationCatalogSnapshot()) { + if (entry != null) { + if (entry.packDisabled() && entry.pack() != null) { + this.disabledAnimationPacks.add(entry.pack().id()); + } + + if (entry.animationDisabled()) { + this.disabledAnimations.add(entry.id()); + } + } + } + } else if (this.config != null) { + this.disabledAnimationPacks.addAll(this.config.getDisabledAnimationPacks()); + this.disabledAnimations.addAll(this.config.getDisabledAnimations()); + } + } + + private void togglePackEnabled(String packId) { + if (this.editable && packId != null && !packId.isBlank()) { + if (this.disabledAnimationPacks.contains(packId)) { + this.disabledAnimationPacks.remove(packId); + } else { + this.disabledAnimationPacks.add(packId); + } + + this.saveDisabledAnimationState(); + } + } + + private void toggleAnimationEnabled(String animationId) { + if (this.editable && animationId != null && !animationId.isBlank()) { + if (this.disabledAnimations.contains(animationId)) { + this.disabledAnimations.remove(animationId); + } else { + this.disabledAnimations.add(animationId); + } + + this.saveDisabledAnimationState(); + } + } + + private void saveDisabledAnimationState() { + if (this.config != null) { + this.config.setDisabledAnimationPacks(new ArrayList<>(this.disabledAnimationPacks)); + this.config.setDisabledAnimations(new ArrayList<>(this.disabledAnimations)); + NonModMenuScreens.saveServerConfig(this.config); + this.rebuildRows(); + this.applyFilter(this.filterField == null ? "" : this.filterField.getText(), false); + } + } + + @Nullable + private Identifier resolvePackIcon(String resourcePackId) { + if (resourcePackId == null || resourcePackId.isBlank()) { + return null; + } + + if (this.packIconByResourcePackId.containsKey(resourcePackId)) { + return this.packIconByResourcePackId.get(resourcePackId); + } + + Identifier textureId = this.loadPackIcon(resourcePackId); + this.packIconByResourcePackId.put(resourcePackId, textureId); + return textureId; + } + + @Nullable + private Identifier loadPackIcon(String resourcePackId) { + MinecraftClient client = MinecraftClient.getInstance(); + ResourceManager manager = this.resolveResourceManagerForAnimdefs(); + if (client != null && manager != null) { + try (Stream stream = manager.streamResourcePacks()) { + ResourcePack pack = stream.filter(candidate -> resourcePackId.equals(candidate.getId())).findFirst().orElse(null); + if (pack == null) { + return null; + } + + try (InputStream input = openRootResource(pack, "pack.png")) { + if (input != null) { + NativeImage image = NativeImage.read(input); + Identifier id = Identifier.of("needsofnature", "dynamic/animation_pack_icons/" + sanitizeTexturePath(resourcePackId)); + client.getTextureManager().registerTexture(id, new NativeImageBackedTexture(id::toString, image)); + return id; + } else { + return null; + } + } + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to load animation pack icon for {}", resourcePackId, e); + return null; + } + } else { + return null; + } + } + + @Nullable + private static InputStream openRootResource(ResourcePack pack, String fileName) { + if (pack != null && fileName != null) { + InputSupplier supplier = pack.openRoot(new String[]{fileName}); + if (supplier == null) { + return null; + } + + try { + return (InputStream)supplier.get(); + } catch (IOException | RuntimeException e) { + return null; + } + } else { + return null; + } + } + + private static String sanitizeTexturePath(String raw) { + if (raw != null && !raw.isBlank()) { + String lower = raw.toLowerCase(Locale.ROOT); + StringBuilder sb = new StringBuilder(lower.length()); + + for (int i = 0; i < lower.length(); i++) { + char c = lower.charAt(i); + if ((c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' && c != '-' && c != '/') { + sb.append('_'); + } else { + sb.append(c); + } + } + + return sb.isEmpty() ? "unknown" : sb.toString(); + } else { + return "unknown"; + } + } + + private void copyRowMissingDetails(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow row) { + if (row != null && row.hasMissingDetails()) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + String text = String.join("\n", row.missingLines()); + client.keyboard.setClipboard(text); + this.showCopyFeedback(Text.translatable("config.needsofnature.loaded_animations.copied_missing")); + } + } + } + + private void showCopyFeedback(Text message) { + this.copyFeedbackText = (Text)(message == null ? Text.literal("") : message); + this.copyFeedbackUntilMs = System.currentTimeMillis() + 1600L; + } + + private void rebuildRows() { + MinecraftClient client = MinecraftClient.getInstance(); + List entries; + if (client != null && client.world != null && NonServerSettingsClient.hasAnimationCatalog()) { + entries = NonServerSettingsClient.animationCatalogSnapshot(); + this.observedCatalogRevision = NonServerSettingsClient.catalogRevision(); + } else { + entries = AnimationCatalogData.capture(this.config).animations(); + } + + List rows = new ArrayList<>(entries.size()); + + for (AnimationCatalogData.Entry entry : entries) { + if (entry != null) { + rows.add(this.buildRow(entry)); + } + } + + this.allRows = List.copyOf(rows); + } + + private NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow buildRow(AnimationCatalogData.Entry definition) { + List actors = buildActorDisplays(definition.actors()); + Integer peakStage = definition.peakStage(); + String peakLabel = peakStage == null ? "-" : "p" + peakStage; + NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceCheck check = evaluateResourceCheck(definition); + NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus status = check.status(); + String key = definition.id(); + Identifier definitionId = Identifier.tryParse(key); + AnimationCatalogData.Pack packInfo = definition.pack(); + String packId = packInfo == null ? "unknown" : packInfo.id(); + String resourcePackId = packInfo == null ? "unknown" : packInfo.resourcePackId(); + String packName = packInfo == null ? "Unknown Pack" : packInfo.name(); + String packAuthor = packInfo == null ? "Unknown" : packInfo.author(); + boolean packDisabled = this.disabledAnimationPacks.contains(packId); + boolean animationDisabled = this.disabledAnimations.contains(key); + String source = definitionId == null ? "unknown:" : definitionId.getNamespace() + ":"; + String title = definitionId == null ? key : definitionId.getPath(); + title = title + (packDisabled ? " [pack off]" : (animationDisabled ? " [off]" : "")); + List detailLines = new ArrayList<>(); + detailLines.add("stages: " + definition.stages().size() + " | peak: " + peakLabel); + detailLines.add(summarizeCatalogStages(definition.stages())); + detailLines.addAll(summarizeCatalogActorRoles(definition.actors())); + String resourcesLine = "resources: anim " + + check.animationFound + + "/" + + check.animationTotal + + " | model " + + check.modelFound + + "/" + + check.modelTotal; + if (check.modelUnchecked > 0) { + resourcesLine = resourcesLine + " | model n/a " + check.modelUnchecked; + } + + List contentTags = definition.contentTags() == null + ? List.of() + : definition.contentTags().stream().filter(Objects::nonNull).map(String::trim).filter(tag -> !tag.isEmpty()).distinct().toList(); + String blockRequirementLine = definition.blockRequirement(); + List infoLines = new ArrayList<>(); + if (!definition.attackEligible()) { + infoLines.add("attacks: disabled"); + } + + if (blockRequirementLine != null) { + infoLines.add(blockRequirementLine); + } + + if (definition.waterRequirement() != null && !"none".equals(definition.waterRequirement())) { + infoLines.add("water: " + definition.waterRequirement()); + } + + if (status != NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.OK) { + infoLines.add(resourcesLine); + } + + if (!check.localModelDetails().isEmpty()) { + int take = Math.min(3, check.localModelDetails().size()); + String localSummary = String.join(", ", check.localModelDetails().subList(0, take)); + int remaining = check.localModelDetails().size() - take; + infoLines.add("local models: " + localSummary + (remaining > 0 ? ", +" + remaining : "")); + } + + boolean hasMissingDetails = check.hasMissingDetails(); + List missingLines = check.missingLinesStructured(); + int entityTypeCount = definition.actors().stream().mapToInt(actor -> actor.entityTypes() == null ? 0 : actor.entityTypes().size()).sum(); + int missingCount = check.totalMissingCount(); + String actorSearch = actors.stream().map(NonModMenuDebugScreens.LoadedAnimationsScreen.ActorDisplay::searchText).collect(Collectors.joining(" ")); + String searchBlob = (definition.id() + + " " + + actorSearch + + " " + + String.join(" ", infoLines) + + " " + + String.join(" ", detailLines) + + " " + + String.join(" ", contentTags) + + " " + + resourcesLine + + " " + + packName + + " " + + packAuthor + + " " + + this.sourceHintByDefinitionId.getOrDefault(key, "unknown") + + (hasMissingDetails ? " " + String.join(" ", missingLines) : "")) + .toLowerCase(Locale.ROOT); + return new NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow( + key, + packId, + resourcePackId, + packName, + packAuthor, + source, + title, + actors, + List.copyOf(detailLines), + List.copyOf(infoLines), + List.copyOf(contentTags), + missingLines, + searchBlob, + status, + missingCount, + entityTypeCount, + packDisabled, + animationDisabled, + hasMissingDetails, + this.expandedRowKeys.contains(key) + ); + } + + private void applyFilter(String rawFilter) { + this.applyFilter(rawFilter, true); + } + + private void applyFilter(String rawFilter, boolean resetScroll) { + String filter = rawFilter == null ? "" : rawFilter.trim().toLowerCase(Locale.ROOT); + List filtered = new ArrayList<>(); + + for (NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow row : this.allRows) { + if (row.matches(filter) && row.matchesStatus(this.statusFilter)) { + filtered.add(row); + } + } + + filtered.sort(this.groupedRowComparator()); + this.filteredCount = filtered.size(); + if (this.loadedAnimationsList != null) { + this.loadedAnimationsList.setRows(this.buildGroupedDisplayRows(filtered)); + if (resetScroll) { + this.loadedAnimationsList.setScrollY(0.0); + } + } + } + + private List buildGroupedDisplayRows( + List filtered + ) { + if (filtered != null && !filtered.isEmpty()) { + Map totalsByPack = new LinkedHashMap<>(); + Map disabledByPack = new LinkedHashMap<>(); + + for (NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow row : this.allRows) { + totalsByPack.merge(row.packId(), 1, Integer::sum); + if (row.animationDisabled()) { + disabledByPack.merge(row.packId(), 1, Integer::sum); + } + } + + List out = new ArrayList<>(); + String currentPackId = null; + List currentRows = new ArrayList<>(); + + for (NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow row : filtered) { + if (!Objects.equals(currentPackId, row.packId())) { + this.appendPackGroup(out, currentPackId, currentRows, totalsByPack, disabledByPack); + currentRows.clear(); + currentPackId = row.packId(); + } + + currentRows.add(row); + } + + this.appendPackGroup(out, currentPackId, currentRows, totalsByPack, disabledByPack); + return List.copyOf(out); + } else { + return List.of(); + } + } + + private void appendPackGroup( + List out, + @Nullable String packId, + List rows, + Map totalsByPack, + Map disabledByPack + ) { + if (out != null && packId != null && rows != null && !rows.isEmpty()) { + NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow first = rows.getFirst(); + out.add( + new NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow( + "pack:" + packId, + packId, + first.resourcePackId(), + first.packName(), + first.packAuthor(), + rows.size(), + totalsByPack.getOrDefault(packId, rows.size()), + disabledByPack.getOrDefault(packId, 0), + this.disabledAnimationPacks.contains(packId), + this.expandedPackIds.contains(packId), + this.resolvePackIcon(first.resourcePackId()) + ) + ); + if (this.expandedPackIds.contains(packId)) { + out.addAll(rows); + } + } + } + + private void togglePackExpanded(String packId) { + if (packId != null && !packId.isBlank()) { + if (this.expandedPackIds.contains(packId)) { + this.expandedPackIds.remove(packId); + } else { + this.expandedPackIds.add(packId); + } + + this.applyFilter(this.filterField == null ? "" : this.filterField.getText(), false); + } + } + + private void toggleRowExpanded(String key) { + if (key != null && !key.isBlank()) { + if (this.expandedRowKeys.contains(key)) { + this.expandedRowKeys.remove(key); + } else { + this.expandedRowKeys.add(key); + } + + this.rebuildRows(); + this.applyFilter(this.filterField == null ? "" : this.filterField.getText(), false); + } + } + + private Comparator rowComparator() { + Comparator comparator = switch (this.sortMode) { + case ID -> Comparator.comparing(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow::key); + case STATUS -> Comparator.comparingInt(row -> row.status().sortRank()) + .thenComparing(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow::key); + case MISSING -> Comparator.comparingInt(row -> row.missingCount()) + .thenComparing(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow::key); + case ENTITIES -> Comparator.comparingInt(row -> row.entityTypeCount()) + .thenComparing(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow::key); + }; + return this.sortAscending ? comparator : comparator.reversed(); + } + + private Comparator groupedRowComparator() { + return Comparator.comparing(row -> row.packName().toLowerCase(Locale.ROOT)) + .thenComparing(row -> row.packId().toLowerCase(Locale.ROOT)) + .thenComparing(this.rowComparator()); + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceCheck evaluateResourceCheck(AnimationCatalogData.Entry definition) { + LinkedHashSet stageIds = new LinkedHashSet<>(); + if (definition.stages() != null) { + for (AnimationCatalogData.Stage stage : definition.stages()) { + if (stage != null) { + Identifier id = Identifier.tryParse(stage.animationId()); + if (id != null) { + stageIds.add(id); + } + } + } + } + + if (stageIds.isEmpty()) { + Identifier id = Identifier.tryParse(definition.id()); + if (id != null) { + stageIds.add(id); + } + } + + int animationTotal = 0; + int animationFound = 0; + int modelTotal = 0; + int modelFound = 0; + int modelUnchecked = 0; + LinkedHashSet missingAnimations = new LinkedHashSet<>(); + LinkedHashSet missingModels = new LinkedHashSet<>(); + LinkedHashSet localModelDetails = new LinkedHashSet<>(); + + for (AnimationCatalogData.Actor actor : definition.actors() == null ? List.of() : definition.actors()) { + if (actor != null) { + String actorKey = actor.key(); + List runtimeTypes = normalizeEntityTypesForRuntime(actor.entityTypes()); + if (runtimeTypes.isEmpty()) { + modelUnchecked++; + + for (Identifier stageId : stageIds) { + Identifier animationResource = GeckoResourceResolver.resolveAnimationResource(stageId, actorKey, null, false); + String animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource, stageId, actorKey, null, false); + animationTotal++; + if (GeckoResourceResolver.hasBakedAnimationKey(animationResource, animationKey)) { + animationFound++; + } else { + missingAnimations.add("actor=" + actorKey + " -> " + missingAnimationDetail(animationResource, animationKey)); + } + } + } else { + for (Identifier entityTypeId : runtimeTypes) { + List modelProviders = modelProvidersFor(definition.pack(), entityTypeId); + NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage coverage = evaluateModelCoverage( + actor.requiredTags(), entityTypeId, modelProviders + ); + modelTotal++; + if (coverage.found()) { + modelFound++; + } else { + missingModels.add(coverage.missingDetail()); + } + + localModelDetails.addAll(coverage.localModelDetails()); + + for (Identifier stageId : stageIds) { + Identifier animationResource = GeckoResourceResolver.resolveAnimationResource(stageId, actorKey, entityTypeId, false); + String animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource, stageId, actorKey, entityTypeId, false); + animationTotal++; + if (GeckoResourceResolver.hasBakedAnimationKey(animationResource, animationKey)) { + animationFound++; + } else { + missingAnimations.add( + "actor=" + actorKey + " type=" + entityTypeId + " -> " + missingAnimationDetail(animationResource, animationKey) + ); + } + } + } + } + } + } + + return new NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceCheck( + animationTotal, + animationFound, + modelTotal, + modelFound, + modelUnchecked, + List.copyOf(missingAnimations), + List.copyOf(missingModels), + List.copyOf(localModelDetails) + ); + } + + private static String missingAnimationDetail(Identifier resource, String animationKey) { + return animationKey != null && !animationKey.isBlank() ? resource + "#" + animationKey : resource + " (actor clip missing)"; + } + + private static List normalizeEntityTypesForRuntime(List entityTypes) { + if (entityTypes != null && !entityTypes.isEmpty()) { + LinkedHashSet normalized = new LinkedHashSet<>(); + + for (String raw : entityTypes) { + if (raw != null && !raw.isBlank()) { + Identifier id = Identifier.tryParse(raw); + if (id != null) { + normalized.add(id); + } + } + } + + List sorted = new ArrayList<>(normalized); + sorted.sort(Comparator.comparing(Identifier::toString)); + return List.copyOf(sorted); + } else { + return List.of(); + } + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage evaluateModelCoverage( + List requiredTags, Identifier entityTypeId, List modelProviders + ) { + Set tags = requiredTags == null ? Set.of() : new HashSet<>(requiredTags); + return evaluateModelCoverage(tags, entityTypeId, modelProviders); + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage evaluateModelCoverage( + Set requiredTags, Identifier entityTypeId, List modelProviders + ) { + List modelRoots = modelProviders.stream().map(NonModMenuDebugScreens.LoadedAnimationsScreen.ModelProvider::root).toList(); + GeckoResourceResolver.ModelAndTexture maleModel = GeckoResourceResolver.resolveModelAndTexture( + entityTypeId, modelRoots, List.of(entityTypeId.getPath()), GenderedModelResolver.ModelGender.MALE + ); + GeckoResourceResolver.ModelAndTexture femaleModel = GeckoResourceResolver.resolveModelAndTexture( + entityTypeId, modelRoots, List.of(entityTypeId.getPath()), GenderedModelResolver.ModelGender.FEMALE + ); + GeckoResourceResolver.ModelAndTexture bothModel = GeckoResourceResolver.resolveModelAndTexture( + entityTypeId, modelRoots, List.of(entityTypeId.getPath()), GenderedModelResolver.ModelGender.BOTH + ); + NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement requirement = genderRequirement(requiredTags); + + boolean found = switch (requirement) { + case ANY -> !maleModel.missingModel() && !femaleModel.missingModel(); + case MALE_ONLY -> !maleModel.missingModel(); + case FEMALE_ONLY -> !femaleModel.missingModel(); + case BOTH_ONLY -> !bothModel.missingModel(); + }; + LinkedHashSet localDetails = new LinkedHashSet<>(); + switch (requirement) { + case ANY: + addLocalModelDetail(localDetails, entityTypeId, "♂", maleModel.modelRoot(), modelProviders); + addLocalModelDetail(localDetails, entityTypeId, "♀", femaleModel.modelRoot(), modelProviders); + break; + case MALE_ONLY: + addLocalModelDetail(localDetails, entityTypeId, "♂", maleModel.modelRoot(), modelProviders); + break; + case FEMALE_ONLY: + addLocalModelDetail(localDetails, entityTypeId, "♀", femaleModel.modelRoot(), modelProviders); + break; + case BOTH_ONLY: + addLocalModelDetail(localDetails, entityTypeId, "♂♀", bothModel.modelRoot(), modelProviders); + } + + if (found) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage.present(List.copyOf(localDetails)); + } + + String detail = switch (requirement) { + case ANY -> entityTypeId + " (need base or both .m/.f)"; + case MALE_ONLY -> entityTypeId + " (need base or .m)"; + case FEMALE_ONLY -> entityTypeId + " (need base or .f)"; + case BOTH_ONLY -> entityTypeId + " (need base or .mf; .m/.f can fall back)"; + }; + return NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage.missingCoverage(detail, List.copyOf(localDetails)); + } + + private static List modelProvidersFor( + AnimationCatalogData.Pack pack, Identifier entityTypeId + ) { + if (pack != null && entityTypeId != null) { + LinkedHashMap providers = new LinkedHashMap<>(); + Identifier localRoot = pack.localModelRoot() == null ? null : Identifier.tryParse(pack.localModelRoot()); + if (localRoot != null) { + providers.put(localRoot, ""); + } + + if (pack.modelImports() != null) { + for (AnimationCatalogData.ModelImport modelImport : pack.modelImports()) { + if (modelImport != null && modelImport.entityTypes() != null && modelImport.entityTypes().contains(entityTypeId.toString())) { + Identifier root = modelImport.modelRoot() == null ? null : Identifier.tryParse(modelImport.modelRoot()); + if (root != null) { + providers.putIfAbsent(root, modelImport.packId()); + } + } + } + } + + return providers.entrySet() + .stream() + .map(entry -> new NonModMenuDebugScreens.LoadedAnimationsScreen.ModelProvider(entry.getKey(), entry.getValue())) + .toList(); + } else { + return List.of(); + } + } + + private static void addLocalModelDetail( + LinkedHashSet details, + Identifier entityTypeId, + String gender, + @Nullable Identifier selectedRoot, + List providers + ) { + if (selectedRoot != null) { + String provider = providers.stream() + .filter(candidate -> candidate.root().equals(selectedRoot)) + .map(NonModMenuDebugScreens.LoadedAnimationsScreen.ModelProvider::label) + .findFirst() + .orElse(selectedRoot.toString()); + String detail = entityTypeId.getPath() + " " + gender; + if (!provider.isBlank()) { + detail = detail + " (" + provider + ")"; + } + + details.add(detail); + } + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement genderRequirement(Set requiredTags) { + if (requiredTags != null && !requiredTags.isEmpty()) { + boolean needsMale = requiredTags.contains("gender.male"); + boolean needsFemale = requiredTags.contains("gender.female"); + if (needsMale && needsFemale) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement.BOTH_ONLY; + } else if (needsMale && !needsFemale) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement.MALE_ONLY; + } else { + return needsFemale && !needsMale + ? NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement.FEMALE_ONLY + : NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement.ANY; + } + } else { + return NonModMenuDebugScreens.LoadedAnimationsScreen.GenderRequirement.ANY; + } + } + + private static String summarizeCatalogStages(List stages) { + if (stages != null && !stages.isEmpty()) { + List parts = new ArrayList<>(); + int previewCount = Math.min(3, stages.size()); + + for (int i = 0; i < previewCount; i++) { + AnimationCatalogData.Stage stage = stages.get(i); + String stageLabel = "p" + (stage.stageNumber() == null ? "?" : stage.stageNumber()); + String loopLabel = stage.loop() ? "loop" : "once"; + String cycle = stage.cycleTicks() > 0.0 ? String.format(Locale.ROOT, "%.2fs", stage.cycleTicks() / 20.0) : "-"; + String speed = String.format(Locale.ROOT, "%.2fx", stage.speed()); + parts.add(stageLabel + " " + loopLabel + " " + cycle + " @" + speed); + } + + if (stages.size() > previewCount) { + parts.add("+" + (stages.size() - previewCount) + " more"); + } + + return "loop/cycle: " + String.join(", ", parts); + } else { + return "loop/cycle: -"; + } + } + + private static List summarizeCatalogActorRoles(List actors) { + List injectors = new ArrayList<>(); + List receivers = new ArrayList<>(); + if (actors != null) { + for (AnimationCatalogData.Actor actor : actors) { + if (actor != null) { + String actorKey = actor.key() != null && !actor.key().isBlank() ? actor.key() : "actor"; + String injectorType = actor.injectorType(); + if (injectorType != null && !injectorType.isBlank() && !"NONE".equalsIgnoreCase(injectorType)) { + injectors.add(actorKey + " (" + injectorType.toUpperCase(Locale.ROOT) + ")"); + } + + if (actor.receiver()) { + receivers.add(actorKey); + } + } + } + } + + return List.of( + "injectors: " + (injectors.isEmpty() ? "none" : String.join(", ", injectors)), + "receivers: " + (receivers.isEmpty() ? "none" : String.join(", ", receivers)) + ); + } + + private static List buildActorDisplays(List actors) { + if (actors != null && !actors.isEmpty()) { + List displays = new ArrayList<>(actors.size()); + + for (int actorIndex = 0; actorIndex < actors.size(); actorIndex++) { + AnimationCatalogData.Actor actor = actors.get(actorIndex); + if (actor != null) { + List entityTypes = new ArrayList<>(); + + for (Identifier id : normalizeEntityTypesForRuntime(actor.entityTypes())) { + String displayName = Registries.ENTITY_TYPE.getOptionalValue(id).map(type -> type.getName().getString()).orElse(id.getPath()); + entityTypes.add( + new NonModMenuDebugScreens.LoadedAnimationsScreen.EntityTypeDisplay( + id.getNamespace(), displayName, id.toString(), "minecraft".equals(id.getNamespace()) + ) + ); + } + + String key = actor.key() != null && !actor.key().isBlank() ? actor.key() : "actor"; + NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge gender = resolveActorGenderBadge(actor.requiredTags(), actor.requiredTagsAny()); + if (gender == NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.NONE + && actor.receiver() + && hasOtherVInjector(actors, actorIndex)) { + gender = NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.FEMALE_IMPLIED; + } + + displays.add(new NonModMenuDebugScreens.LoadedAnimationsScreen.ActorDisplay(key, List.copyOf(entityTypes), gender)); + } + } + + return List.copyOf(displays); + } else { + return List.of(); + } + } + + private static boolean hasOtherVInjector(List actors, int receiverIndex) { + for (int actorIndex = 0; actorIndex < actors.size(); actorIndex++) { + if (actorIndex != receiverIndex) { + AnimationCatalogData.Actor actor = actors.get(actorIndex); + if (actor != null && "V".equalsIgnoreCase(actor.injectorType())) { + return true; + } + } + } + + return false; + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge resolveActorGenderBadge( + List requiredTags, List requiredTagsAny + ) { + Set required = requiredTags == null ? Set.of() : new HashSet<>(requiredTags); + Set any = requiredTagsAny == null ? Set.of() : new HashSet<>(requiredTagsAny); + boolean requiredMale = required.contains("gender.male"); + boolean requiredFemale = required.contains("gender.female"); + boolean anyMale = any.contains("gender.male"); + boolean anyFemale = any.contains("gender.female"); + boolean anyOther = any.stream().anyMatch(tag -> !"gender.male".equals(tag) && !"gender.female".equals(tag)); + if (requiredMale && requiredFemale) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.BOTH; + } + + if (requiredMale) { + return !any.isEmpty() && anyFemale && !anyMale && !anyOther + ? NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.BOTH + : NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.MALE; + } + + if (!requiredFemale) { + if (anyOther) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.NONE; + } else if (anyMale && anyFemale) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.EITHER; + } else if (anyMale) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.MALE; + } else { + return anyFemale + ? NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.FEMALE + : NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.NONE; + } + } else { + return !any.isEmpty() && anyMale && !anyFemale && !anyOther + ? NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.BOTH + : NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.FEMALE; + } + } + + private record ActorDisplay( + String key, + List entityTypes, + NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge gender + ) { + private String searchText() { + String types = this.entityTypes == null + ? "" + : this.entityTypes.stream().map(NonModMenuDebugScreens.LoadedAnimationsScreen.EntityTypeDisplay::searchText).collect(Collectors.joining(" ")); + return this.key + " " + types + " " + (this.gender == null ? "" : this.gender.name()); + } + } + + private enum ActorGenderBadge { + NONE, + MALE, + FEMALE, + FEMALE_IMPLIED, + BOTH, + EITHER; + } + + private record EntityTypeDisplay(String namespace, String displayName, String rawId, boolean vanilla) { + private String searchText() { + return this.rawId + " " + this.namespace + " " + this.displayName; + } + } + + private enum GenderRequirement { + ANY, + MALE_ONLY, + FEMALE_ONLY, + BOTH_ONLY; + } + + private record LoadedAnimationRow( + String key, + String packId, + String resourcePackId, + String packName, + String packAuthor, + String source, + String title, + List actors, + List detailLines, + List infoLines, + List contentTags, + List missingLines, + String searchBlob, + NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus status, + int missingCount, + int entityTypeCount, + boolean packDisabled, + boolean animationDisabled, + boolean hasMissingDetails, + boolean expanded + ) implements NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow { + boolean effectivelyDisabled() { + return this.packDisabled || this.animationDisabled; + } + + private boolean matches(String filter) { + return filter != null && !filter.isBlank() ? this.searchBlob.contains(filter) : true; + } + + private boolean matchesStatus(NonModMenuDebugScreens.LoadedAnimationsScreen.StatusFilter filter) { + return switch (filter) { + case ALL -> true; + case MISSING -> this.status == NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.MISSING; + case PARTIAL -> this.status == NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.PARTIAL; + case OK -> this.status == NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.OK; + }; + } + } + + private static final class LoadedAnimationsList extends ElementListWidget { + private static final int TAG_CARD_HEIGHT = 13; + private static final int TAG_CARD_PADDING = 4; + private static final int TAG_HORIZONTAL_GAP = 4; + private static final int TAG_VERTICAL_GAP = 3; + private static final int CARD_BOTTOM_PADDING = 8; + private static final int ANIMATION_PREVIEW_SIZE = 46; + private static final int ANIMATION_PREVIEW_COLUMN_WIDTH = 54; + private static final int MIN_ANIMATION_CARD_HEIGHT = 64; + private final TextRenderer textRenderer; + private final Consumer onTogglePackExpanded; + private final Consumer onToggleRow; + private final Consumer onCopyMissing; + private final Consumer onTogglePackEnabled; + private final Consumer onToggleAnimationEnabled; + private final BooleanSupplier editableSupplier; + + private LoadedAnimationsList( + MinecraftClient client, + int width, + int height, + int top, + TextRenderer textRenderer, + Consumer onTogglePackExpanded, + Consumer onToggleRow, + Consumer onCopyMissing, + Consumer onTogglePackEnabled, + Consumer onToggleAnimationEnabled, + BooleanSupplier editableSupplier + ) { + super(client, width, height, top, 60); + this.textRenderer = textRenderer; + this.onTogglePackExpanded = onTogglePackExpanded; + this.onToggleRow = onToggleRow; + this.onCopyMissing = onCopyMissing; + this.onTogglePackEnabled = onTogglePackEnabled; + this.onToggleAnimationEnabled = onToggleAnimationEnabled; + this.editableSupplier = editableSupplier; + this.centerListVertically = false; + } + + private void setRows(List rows) { + this.clearEntries(); + + for (NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow row : rows) { + this.addEntry( + new NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationsList.RowEntry( + row, + this.textRenderer, + this.onToggleRow, + this.onCopyMissing, + this.onTogglePackExpanded, + this.onTogglePackEnabled, + this.onToggleAnimationEnabled, + this.editableSupplier + ), + this.rowHeight(row) + ); + } + } + + private int rowHeight(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow row) { + if (row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow) { + return 34; + } + + if (row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRow) { + int infoLines = Math.max(0, animationRow.infoLines() == null ? 0 : animationRow.infoLines().size()); + int contentEnd = 23 + infoLines * 9; + int tagRows = countTagRows(this.textRenderer, animationRow.contentTags(), Math.max(20, this.getRowWidth() - 12 - 54)); + if (tagRows > 0) { + contentEnd += 4 + tagRows * 13 + (tagRows - 1) * 3; + } + + int collapsedHeight = contentEnd + 18 + 8; + if (!animationRow.expanded()) { + return Math.max(64, collapsedHeight); + } + + int detailLines = animationRow.detailLines() == null ? 0 : animationRow.detailLines().size(); + int missingLines = animationRow.hasMissingDetails() && animationRow.missingLines() != null + ? Math.min(10, animationRow.missingLines().size()) + : 0; + return Math.max(64, collapsedHeight + detailLines * 9 + (missingLines > 0 ? 2 + missingLines * 9 : 0)); + } else { + return 48; + } + } + + private static int countTagRows(TextRenderer textRenderer, List tags, int maxWidth) { + if (tags != null && !tags.isEmpty() && maxWidth > 0) { + int rows = 1; + int usedWidth = 0; + + for (String tag : tags) { + if (tag != null && !tag.isBlank()) { + int width = Math.min(maxWidth, textRenderer.getWidth(tag) + 8); + if (usedWidth > 0 && usedWidth + 4 + width > maxWidth) { + rows++; + usedWidth = width; + } else { + usedWidth += (usedWidth == 0 ? 0 : 4) + width; + } + } + } + + return usedWidth == 0 ? 0 : rows; + } else { + return 0; + } + } + + public int getRowWidth() { + return Math.min(500, Math.max(120, this.getWidth() - 24)); + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + protected int getScrollbarX() { + return Math.min(this.getWidth() - 8, this.getRowLeft() + this.getRowWidth() + 6); + } + + private static final class RowEntry + extends net.minecraft.client.gui.widget.ElementListWidget.Entry { + private final NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow row; + private final TextRenderer textRenderer; + private final Consumer onToggleRow; + private final Consumer onCopyMissing; + private final Consumer onTogglePackExpanded; + private final Consumer onTogglePackEnabled; + private final Consumer onToggleAnimationEnabled; + private final BooleanSupplier editableSupplier; + private final NonModMenuScreens.FlatTextButton toggleButton; + private final NonModMenuScreens.FlatTextButton copyMissingButton; + private final ButtonWidget enabledButton; + private final List widgets; + + private RowEntry( + NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow row, + TextRenderer textRenderer, + Consumer onToggleRow, + Consumer onCopyMissing, + Consumer onTogglePackExpanded, + Consumer onTogglePackEnabled, + Consumer onToggleAnimationEnabled, + BooleanSupplier editableSupplier + ) { + this.row = row; + this.textRenderer = textRenderer; + this.onToggleRow = onToggleRow; + this.onCopyMissing = onCopyMissing; + this.onTogglePackExpanded = onTogglePackExpanded; + this.onTogglePackEnabled = onTogglePackEnabled; + this.onToggleAnimationEnabled = onToggleAnimationEnabled; + this.editableSupplier = editableSupplier == null ? () -> true : editableSupplier; + this.toggleButton = new NonModMenuScreens.FlatTextButton( + 18, + Text.literal( + row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRow && animationRow.expanded() ? "-" : "+" + ), + button -> { + if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow headerRow) { + if (this.onTogglePackExpanded != null) { + this.onTogglePackExpanded.accept(headerRow.packId()); + } + } else if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRowx && this.onToggleRow != null) { + this.onToggleRow.accept(animationRowx.key()); + } + } + ); + this.copyMissingButton = new NonModMenuScreens.FlatTextButton( + 24, + Text.literal("MS"), + button -> { + if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRowx + && animationRowx.hasMissingDetails() + && this.onCopyMissing != null) { + this.onCopyMissing.accept(animationRowx); + } + } + ); + this.enabledButton = ButtonWidget.builder( + Text.literal("ON"), + button -> { + if (this.editableSupplier.getAsBoolean()) { + if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow headerRow) { + if (this.onTogglePackEnabled != null) { + this.onTogglePackEnabled.accept(headerRow.packId()); + } + } else if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRowx + && this.onToggleAnimationEnabled != null) { + this.onToggleAnimationEnabled.accept(animationRowx.key()); + } + } + } + ) + .dimensions(0, 0, 42, 18) + .build(); + List ws = new ArrayList<>(); + ws.add(this.enabledButton); + if (row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRow) { + ws.add(this.toggleButton); + if (animationRow.hasMissingDetails()) { + ws.add(this.copyMissingButton); + } + } + + this.widgets = List.copyOf(ws); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int x = this.getContentX() + 4; + int y = this.getContentY() + 3; + int right = this.getContentX() + this.getContentWidth() - 2; + if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow headerRow) { + this.renderPackHeader(context, mouseX, mouseY, deltaTicks, x, y, right, headerRow); + } else if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRow) { + int var47 = this.getContentX() + 2; + int cardTop = this.getContentY() + 1; + int cardRight = this.getContentX() + this.getContentWidth() - 2; + int cardBottom = this.getContentY() + this.getContentHeight() - 2; + int cardBorder = animationRow.effectivelyDisabled() ? -14079703 : (hovered ? -9281155 : -11450790); + int cardBackground = animationRow.effectivelyDisabled() ? 1108809495 : (hovered ? 1747199012 : 1427971357); + context.fill(var47 + 1, cardTop + 1, cardRight - 1, cardBottom - 1, cardBackground); + context.fill(var47, cardTop, cardRight, cardTop + 1, cardBorder); + context.fill(var47, cardBottom - 1, cardRight, cardBottom, cardBorder); + context.fill(var47, cardTop + 1, var47 + 1, cardBottom - 1, cardBorder); + context.fill(cardRight - 1, cardTop + 1, cardRight, cardBottom - 1, cardBorder); + x = this.getContentX() + 6; + y = this.getContentY() + 5; + right = this.getContentX() + this.getContentWidth() - 4; + this.enabledButton.setMessage(Text.literal(animationRow.animationDisabled() ? "OFF" : "ON")); + this.enabledButton.active = this.editableSupplier.getAsBoolean(); + this.enabledButton.setX(right - 44); + this.enabledButton.setY(y - 3); + this.enabledButton.render(context, mouseX, mouseY, deltaTicks); + if (animationRow.hasMissingDetails()) { + this.copyMissingButton.setX(right - 86); + this.copyMissingButton.setY(y - 1); + this.copyMissingButton.render(context, mouseX, mouseY, deltaTicks); + } + + NonAnimationPreviewRenderer.draw(context, this.textRenderer, animationRow.key(), x, y, 46); + if (animationRow.effectivelyDisabled()) { + context.fill(x, y, x + 46, y + 46, 1711276032); + } + + int textX = x + 54; + int statusButtonLeft = animationRow.hasMissingDetails() ? right - 86 : right - 44; + String statusText = "[" + Text.translatable(animationRow.status().translationKey).getString() + "]"; + int statusWidth = this.textRenderer.getWidth(statusText); + int statusX = Math.max(textX, statusButtonLeft - statusWidth - 6); + int textWidth = Math.max(20, statusX - textX - 6); + int titleColor = animationRow.effectivelyDisabled() ? -8947849 : animationRow.status().color; + int statusColor = animationRow.effectivelyDisabled() ? -8947849 : animationRow.status().color; + int sourceColor = animationRow.effectivelyDisabled() ? -10526881 : -8947849; + String visibleSource = this.textRenderer.trimToWidth(animationRow.source(), textWidth); + context.drawTextWithShadow(this.textRenderer, visibleSource, textX, y, sourceColor); + int sourceWidth = this.textRenderer.getWidth(visibleSource); + int remainingTitleWidth = Math.max(0, textWidth - sourceWidth); + if (remainingTitleWidth > 0) { + context.drawTextWithShadow( + this.textRenderer, this.textRenderer.trimToWidth(animationRow.title(), remainingTitleWidth), textX + sourceWidth, y, titleColor + ); + } + + context.drawTextWithShadow(this.textRenderer, statusText, statusX, y, statusColor); + Text actorText = this.buildActorText(animationRow); + List actorLines = this.textRenderer.wrapLines(actorText, textWidth); + if (!actorLines.isEmpty()) { + context.drawTextWithShadow(this.textRenderer, actorLines.getFirst(), textX, y + 9, -1); + } + + List infoLines = animationRow.infoLines() == null ? List.of() : animationRow.infoLines(); + + for (int i = 0; i < infoLines.size(); i++) { + int color = animationRow.effectivelyDisabled() + ? -8947849 + : ( + infoLines.get(i).startsWith("attacks:") + ? -2710919 + : (infoLines.get(i).startsWith("block:") ? -4795766 : (infoLines.get(i).startsWith("local models:") ? -4086064 : -7303024)) + ); + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(infoLines.get(i), textWidth), textX, y + 18 + i * 9, color); + } + + int detailsY = y + 18 + infoLines.size() * 9; + List contentTags = animationRow.contentTags() == null ? List.of() : animationRow.contentTags(); + if (!contentTags.isEmpty()) { + detailsY = this.renderTagCards(context, contentTags, textX, detailsY + 2, right - 2, mouseX, mouseY, animationRow.effectivelyDisabled()) + + 2; + } + + int dividerColor = animationRow.effectivelyDisabled() ? -13816531 : -12306101; + context.fill(textX, detailsY + 1, right - 2, detailsY + 2, dividerColor); + int footerY = detailsY + 3; + int detailsTextX = textX + 20; + int detailsWidth = Math.max(20, right - detailsTextX - 2); + this.toggleButton.setMessage(Text.literal(animationRow.expanded() ? "-" : "+")); + this.toggleButton.setX(textX); + this.toggleButton.setY(footerY - 1); + this.toggleButton.render(context, mouseX, mouseY, deltaTicks); + context.drawTextWithShadow( + this.textRenderer, + this.textRenderer.trimToWidth(Text.translatable("config.needsofnature.loaded_animations.details").getString(), detailsWidth), + detailsTextX, + footerY, + animationRow.effectivelyDisabled() ? -9474193 : -6642524 + ); + if (animationRow.expanded()) { + int lineY = footerY + 13; + List detailLines = animationRow.detailLines() == null ? List.of() : animationRow.detailLines(); + + for (int i = 0; i < detailLines.size(); i++) { + context.drawTextWithShadow( + this.textRenderer, + this.textRenderer.trimToWidth(detailLines.get(i), detailsWidth), + detailsTextX, + lineY, + animationRow.effectivelyDisabled() ? -9474193 : (i == 0 ? -4671304 : -7303024) + ); + lineY += 9; + } + + if (animationRow.hasMissingDetails()) { + lineY += 2; + List missingLines = animationRow.missingLines(); + int maxLines = Math.min(10, missingLines == null ? 0 : missingLines.size()); + + for (int i = 0; i < maxLines; i++) { + context.drawTextWithShadow( + this.textRenderer, + this.textRenderer.trimToWidth(missingLines.get(i), detailsWidth), + detailsTextX, + lineY, + animationRow.effectivelyDisabled() ? -9474193 : -7303024 + ); + lineY += 9; + } + } + } + } + } + + private Text buildActorText(NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedAnimationRow animationRow) { + boolean disabled = animationRow.effectivelyDisabled(); + int labelColor = disabled ? 7829367 : 9079434; + int entityColor = disabled ? 7829367 : 13684944; + int namespaceColor = disabled ? 6710886 : 7570829; + int separatorColor = disabled ? 6710886 : 7829367; + List actors = animationRow.actors() == null ? List.of() : animationRow.actors(); + if (actors.isEmpty()) { + return coloredText("No actors", labelColor); + } + + MutableText line = Text.literal(""); + + for (int actorIndex = 0; actorIndex < actors.size(); actorIndex++) { + if (actorIndex > 0) { + line.append(coloredText(", ", separatorColor)); + } + + NonModMenuDebugScreens.LoadedAnimationsScreen.ActorDisplay actor = actors.get(actorIndex); + line.append(coloredText(actor.key() + ": ", labelColor)); + List entityTypes = actor.entityTypes() == null + ? List.of() + : actor.entityTypes(); + if (entityTypes.isEmpty()) { + line.append(coloredText("Any entity", entityColor)); + } else { + for (int typeIndex = 0; typeIndex < entityTypes.size(); typeIndex++) { + if (typeIndex > 0) { + line.append(coloredText(" | ", separatorColor)); + } + + NonModMenuDebugScreens.LoadedAnimationsScreen.EntityTypeDisplay entityType = entityTypes.get(typeIndex); + if (!entityType.vanilla()) { + line.append(coloredText(entityType.namespace() + ": ", namespaceColor)); + } + + line.append(coloredText(entityType.displayName(), entityColor)); + } + } + + appendGenderBadge(line, actor.gender(), disabled); + } + + return line; + } + + private static MutableText coloredText(String value, int color) { + return Text.literal(value).styled(style -> style.withColor(color)); + } + + private static void appendGenderBadge(MutableText line, NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge badge, boolean disabled) { + if (badge != null && badge != NonModMenuDebugScreens.LoadedAnimationsScreen.ActorGenderBadge.NONE) { + int neutral = disabled ? 7829367 : 10066329; + int male = disabled ? 7829367 : 5880040; + int female = disabled ? 7829367 : 15239605; + line.append(coloredText(" ", neutral)); + switch (badge) { + case NONE: + default: + break; + case MALE: + line.append(coloredText("♂", male)); + break; + case FEMALE: + line.append(coloredText("♀", female)); + break; + case FEMALE_IMPLIED: + line.append(coloredText("♀", neutral)); + break; + case BOTH: + line.append(coloredText("♂", male)).append(coloredText("♀", female)); + break; + case EITHER: + line.append(coloredText("♂", male)).append(coloredText("/", neutral)).append(coloredText("♀", female)); + } + } + } + + private int renderTagCards(DrawContext context, List tags, int startX, int startY, int endX, int mouseX, int mouseY, boolean disabled) { + int maxWidth = Math.max(1, endX - startX); + int x = startX; + int y = startY; + String truncatedHoveredTag = null; + + for (String tag : tags) { + if (tag != null && !tag.isBlank()) { + String display = NonTagLocalizer.localize(tag); + int naturalWidth = this.textRenderer.getWidth(display) + 8; + int cardWidth = Math.min(maxWidth, naturalWidth); + if (x > startX && x + cardWidth > endX) { + x = startX; + y += 16; + } + + boolean hovered = mouseX >= x && mouseX < x + cardWidth && mouseY >= y && mouseY < y + 13; + int border = disabled ? -11974327 : (hovered ? -7703658 : -10135953); + int background = disabled ? -1339348181 : (hovered ? -532925628 : -802281165); + int textColor = disabled ? -8947849 : -1712918; + context.fill(x, y, x + cardWidth, y + 13, border); + context.fill(x + 1, y + 1, x + cardWidth - 1, y + 13 - 1, background); + context.drawText(this.textRenderer, this.textRenderer.trimToWidth(display, Math.max(1, cardWidth - 8)), x + 4, y + 2, textColor, false); + if (hovered && naturalWidth > cardWidth) { + truncatedHoveredTag = display; + } + + x += cardWidth + 4; + } + } + + if (truncatedHoveredTag != null) { + context.drawTooltip(this.textRenderer, Text.literal(truncatedHoveredTag), mouseX, mouseY); + } + + return y + 13; + } + + public boolean mouseClicked(Click click, boolean doubled) { + if (this.row instanceof NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow headerRow + && click.button() == 0 + && this.onTogglePackExpanded != null + && !this.enabledButton.isMouseOver(click.x(), click.y())) { + this.onTogglePackExpanded.accept(headerRow.packId()); + return true; + } else { + return super.mouseClicked(click, doubled); + } + } + + private void renderPackHeader( + DrawContext context, + int mouseX, + int mouseY, + float delta, + int x, + int y, + int right, + NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedPackHeaderRow headerRow + ) { + this.enabledButton.setMessage(Text.literal(headerRow.disabled() ? "OFF" : "ON")); + this.enabledButton.active = this.editableSupplier.getAsBoolean(); + this.enabledButton.setX(right - 44); + this.enabledButton.setY(y + 4); + this.enabledButton.render(context, mouseX, mouseY, delta); + int iconSize = 24; + int iconX = x; + int iconY = y + 1; + context.fill(iconX - 1, iconY - 1, iconX + iconSize + 1, iconY + iconSize + 1, -14671840); + if (headerRow.iconTexture() != null) { + context.drawTexture(RenderPipelines.GUI_TEXTURED, headerRow.iconTexture(), iconX, iconY, 0.0F, 0.0F, iconSize, iconSize, iconSize, iconSize); + } else { + context.fill(iconX, iconY, iconX + iconSize, iconY + iconSize, -12829636); + context.drawTextWithShadow(this.textRenderer, "NoN", iconX + 2, iconY + 8, -4671304); + } + + int textX = iconX + iconSize + 7; + int textRight = right - 48; + String disabledBadge = headerRow.disabledAnimations() > 0 + ? "[" + + Text.translatable("config.needsofnature.loaded_animations.disabled_count", new Object[]{headerRow.disabledAnimations()}).getString() + + "]" + : ""; + int disabledBadgeWidth = disabledBadge.isEmpty() ? 0 : this.textRenderer.getWidth(disabledBadge); + int badgeRight = textRight; + int disabledBadgeX = badgeRight - disabledBadgeWidth; + if (!disabledBadge.isEmpty()) { + badgeRight = disabledBadgeX - 6; + } + + int titleRight = badgeRight; + int titleWidth = Math.max(20, titleRight - textX); + int subtitleWidth = Math.max(20, textRight - textX); + int titleColor = headerRow.disabled() ? -7829368 : -1; + String title = headerRow.packName() + + " (" + + headerRow.visibleAnimations() + + "/" + + headerRow.totalAnimations() + + ")" + + (headerRow.expanded() ? "" : " [collapsed]"); + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(title, titleWidth), textX, y + 2, titleColor); + if (!disabledBadge.isEmpty()) { + context.drawTextWithShadow(this.textRenderer, disabledBadge, disabledBadgeX, y + 2, headerRow.disabled() ? -7702680 : -2778520); + } + + String subtitle = "author: " + headerRow.packAuthor() + " | id: " + headerRow.packId(); + context.drawTextWithShadow(this.textRenderer, this.textRenderer.trimToWidth(subtitle, subtitleWidth), textX, y + 13, -6645094); + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + } + } + + private interface LoadedListRow { + String key(); + } + + private record LoadedPackHeaderRow( + String key, + String packId, + String resourcePackId, + String packName, + String packAuthor, + int visibleAnimations, + int totalAnimations, + int disabledAnimations, + boolean disabled, + boolean expanded, + @Nullable Identifier iconTexture + ) implements NonModMenuDebugScreens.LoadedAnimationsScreen.LoadedListRow { + } + + private record ModelCoverage(boolean found, String missingDetail, List localModelDetails) { + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage present(List localModelDetails) { + return new NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage(true, "", localModelDetails); + } + + private static NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage missingCoverage(String detail, List localModelDetails) { + return new NonModMenuDebugScreens.LoadedAnimationsScreen.ModelCoverage(false, detail, localModelDetails); + } + } + + private record ModelProvider(Identifier root, String label) { + } + + private record ResourceCheck( + int animationTotal, + int animationFound, + int modelTotal, + int modelFound, + int modelUnchecked, + List missingAnimationDetails, + List missingModelDetails, + List localModelDetails + ) { + private NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus status() { + boolean animationsOk = this.animationTotal == 0 || this.animationFound == this.animationTotal; + boolean modelsOk = this.modelTotal == 0 || this.modelFound == this.modelTotal; + if (animationsOk && modelsOk) { + return NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.OK; + } + + boolean anyFound = this.animationFound > 0 || this.modelFound > 0; + boolean anyChecked = this.animationTotal > 0 || this.modelTotal > 0; + return anyChecked && !anyFound + ? NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.MISSING + : NonModMenuDebugScreens.LoadedAnimationsScreen.ResourceStatus.PARTIAL; + } + + private boolean hasMissingDetails() { + return this.missingAnimationDetails != null && !this.missingAnimationDetails.isEmpty() + || this.missingModelDetails != null && !this.missingModelDetails.isEmpty(); + } + + private int totalMissingCount() { + int anim = this.missingAnimationDetails == null ? 0 : this.missingAnimationDetails.size(); + int model = this.missingModelDetails == null ? 0 : this.missingModelDetails.size(); + return anim + model; + } + + private List missingLinesStructured() { + if (!this.hasMissingDetails()) { + return List.of(); + } + + List lines = new ArrayList<>(); + lines.add("missing:"); + if (this.missingAnimationDetails != null && !this.missingAnimationDetails.isEmpty()) { + lines.add("anim:"); + addMissingLines(lines, this.missingAnimationDetails, 3); + } else { + lines.add("anim: none"); + } + + if (this.missingModelDetails != null && !this.missingModelDetails.isEmpty()) { + lines.add("model:"); + addMissingLines(lines, this.missingModelDetails, 3); + } else { + lines.add("model: none"); + } + + return List.copyOf(lines); + } + + private static void addMissingLines(List out, List values, int maxEntries) { + if (values != null && !values.isEmpty()) { + int safeMax = Math.max(1, maxEntries); + int take = Math.min(safeMax, values.size()); + + for (int i = 0; i < take; i++) { + out.add(values.get(i)); + } + + int remaining = values.size() - take; + if (remaining > 0) { + out.add("+" + remaining + " more"); + } + } + } + } + + private enum ResourceStatus { + OK("config.needsofnature.loaded_animations.status.ok", -11141291), + PARTIAL("config.needsofnature.loaded_animations.status.partial", -8090), + MISSING("config.needsofnature.loaded_animations.status.missing", -43691); + + private final String translationKey; + private final int color; + + ResourceStatus(String translationKey, int color) { + this.translationKey = translationKey; + this.color = color; + } + + private int sortRank() { + return switch (this) { + case OK -> 2; + case PARTIAL -> 1; + case MISSING -> 0; + }; + } + } + + private enum SortMode { + ID, + STATUS, + MISSING, + ENTITIES; + } + + private enum StatusFilter { + ALL, + MISSING, + PARTIAL, + OK; + } + } + + static class OffspringCountConfigScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private boolean serverConfigEditable; + private Map appliedMap; + private NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList offspringList; + private final List rows = new ArrayList<>(); + private ButtonWidget applyButton; + private ButtonWidget doneButton; + + protected OffspringCountConfigScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.offspring_count_title")); + this.parent = parent; + this.config = config; + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + this.appliedMap = new LinkedHashMap<>(config.getEntityProfilesByEntity()); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + List preserved = this.snapshotRows(); + if (preserved == null) { + preserved = new ArrayList<>(); + + for (Entry entry : NonEntityProfiles.resolveEffectiveProfilesMap().entrySet()) { + NonConfig.EntityProfile profile = entry.getValue() == null + ? NonEntityProfiles.resolve(Identifier.tryParse(entry.getKey())).toConfigProfile() + : entry.getValue().sanitized(); + NonConfig.EntityProfile configOverride = this.config.getEntityProfilesByEntity().get(entry.getKey()); + preserved.add( + new NonModMenuDebugScreens.OffspringCountConfigScreen.RowData( + entry.getKey(), + configOverride != null && configOverride.pregnancyChancePercent() != null ? String.valueOf(configOverride.pregnancyChancePercent()) : "", + String.valueOf(profile.offspringMin() == null ? 1 : profile.offspringMin()), + String.valueOf(profile.offspringMax() == null ? 1 : profile.offspringMax()), + profile.birthEntity() == null ? entry.getKey() : profile.birthEntity(), + profile.birthMode() == null ? "direct" : profile.birthMode(), + profile.egg() == null ? NonConfig.EggProfile.defaults() : profile.egg() + ) + ); + } + } + + int listTop = 44; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + this.offspringList = new NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(this.offspringList); + this.rows.clear(); + + for (NonModMenuDebugScreens.OffspringCountConfigScreen.RowData data : preserved) { + this.addRow(data.id(), data.chance(), data.min(), data.max(), data.birthEntity(), data.birthMode(), data.eggProfile()); + } + + if (this.rows.isEmpty()) { + this.addRow("", "", "1", "1", "", "direct", NonConfig.EggProfile.defaults()); + } + + int centerX = this.width / 2; + ButtonWidget addButton = ButtonWidget.builder(Text.translatable("config.needsofnature.offspring_count_add"), button -> { + this.addRow("", "", "1", "1", "", "direct", NonConfig.EggProfile.defaults()); + this.updateApplyButton(); + }).dimensions(centerX - 100, this.height - 52, 96, 20).build(); + addButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(addButton, "config.needsofnature.tooltip.offspring_count_add"); + this.addDrawableChild(addButton); + ButtonWidget resetDefaultsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.offspring_count_reset"), + button -> MinecraftClient.getInstance() + .setScreen(new NonModMenuDebugScreens.OffspringCountConfigScreen.ResetOffspringCountConfirmScreen(this, () -> { + this.applyDefaultsToRows(); + this.updateApplyButton(); + MinecraftClient.getInstance().setScreen(this); + })) + ) + .dimensions(centerX + 4, this.height - 52, 96, 20) + .build(); + resetDefaultsButton.active = this.serverConfigEditable; + NonModMenuScreens.setTooltip(resetDefaultsButton, "config.needsofnature.tooltip.offspring_count_reset"); + this.addDrawableChild(resetDefaultsButton); + this.applyButton = ButtonWidget.builder(Text.translatable("config.needsofnature.apply"), button -> { + if (this.serverConfigEditable) { + Map next = this.collectMapFromFields(); + this.config.setEntityProfilesByEntity(next); + NonModMenuScreens.saveServerConfig(this.config); + this.appliedMap = new LinkedHashMap<>(this.config.getEntityProfilesByEntity()); + this.updateApplyButton(); + } + }).dimensions(centerX - 100, this.height - 28, 96, 20).build(); + NonModMenuScreens.setTooltip(this.applyButton, "config.needsofnature.tooltip.apply"); + this.addDrawableChild(this.applyButton); + this.doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, this.height - 28, 96, 20) + .build(); + NonModMenuScreens.setTooltip(this.doneButton, "config.needsofnature.tooltip.done_unsaved"); + this.addDrawableChild(this.doneButton); + this.updateApplyButton(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateApplyButton(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + this.drawHeader(context); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void drawHeader(DrawContext context) { + if (this.offspringList != null) { + int rowX = this.offspringList.getRowLeft(); + int rowWidth = this.offspringList.getRowWidth(); + int removeW = 20; + int chanceW = 44; + int minW = 34; + int maxW = 34; + int birthW = 140; + int modeW = 54; + int eggW = 42; + int idW = Math.max(105, rowWidth - removeW - chanceW - minW - maxW - birthW - modeW - eggW - 48); + int chanceX = rowX + idW + 6; + int minX = chanceX + chanceW + 6; + int maxX = minX + minW + 6; + int birthX = maxX + maxW + 6; + int modeX = birthX + birthW + 6; + int eggX = modeX + modeW + 6; + int y = 34; + int color = -4342339; + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.offspring_count_column.entity"), rowX + 4, y, color); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.entity_profile_column.chance"), chanceX + chanceW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.offspring_count_column.min"), minX + minW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.offspring_count_column.max"), maxX + maxW / 2, y, color + ); + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.entity_profile_column.birth_entity"), birthX + 4, y, color); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.entity_profile_column.birth_mode"), modeX + modeW / 2, y, color + ); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.entity_profile_column.egg"), eggX + eggW / 2, y, color + ); + } + } + + private void addRow( + String idText, String chanceText, String minText, String maxText, String birthEntityText, String birthModeText, NonConfig.EggProfile eggProfile + ) { + NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry[] holder = new NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry[1]; + NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry entry = new NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry( + this.textRenderer, idText, chanceText, minText, maxText, birthEntityText, birthModeText, eggProfile, () -> { + NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry current = holder[0]; + if (current != null) { + this.rows.removeIf(row -> row == current); + this.offspringList.removeEntryRow(current); + this.updateApplyButton(); + } + }, this::chanceSuggestionFor, this::openEggSettings + ); + holder[0] = entry; + entry.setOnChange(this::updateApplyButton); + entry.setEditable(this.serverConfigEditable); + this.rows.add(entry); + this.offspringList.addEntryRow(entry); + } + + private void updateApplyButton() { + if (this.applyButton != null) { + if (!this.serverConfigEditable) { + this.applyButton.active = false; + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable("config.needsofnature.done")); + } + } else { + Map current = this.collectMapFromFields(); + boolean hasUnsavedChanges = !current.equals(this.appliedMap); + this.applyButton.active = hasUnsavedChanges; + if (this.doneButton != null) { + this.doneButton.setMessage(Text.translatable(hasUnsavedChanges ? "config.needsofnature.done_unsaved" : "config.needsofnature.done")); + } + } + } + } + + private void applyDefaultsToRows() { + if (this.offspringList != null) { + for (NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry entry : new ArrayList<>(this.rows)) { + this.offspringList.removeEntryRow(entry); + } + } + + this.rows.clear(); + + for (Entry entry : NonEntityProfiles.resolveDefaultProfilesMap().entrySet()) { + NonConfig.EntityProfile profile = entry.getValue(); + this.addRow( + entry.getKey(), + "", + String.valueOf(profile.offspringMin() == null ? 1 : profile.offspringMin()), + String.valueOf(profile.offspringMax() == null ? 1 : profile.offspringMax()), + profile.birthEntity() == null ? entry.getKey() : profile.birthEntity(), + profile.birthMode() == null ? "direct" : profile.birthMode(), + profile.egg() == null ? NonConfig.EggProfile.defaults() : profile.egg() + ); + } + + if (this.rows.isEmpty()) { + this.addRow("", "", "1", "1", "", "direct", NonConfig.EggProfile.defaults()); + } + } + + private Map collectMapFromFields() { + Map map = new LinkedHashMap<>(); + + for (NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry row : this.rows) { + if (row != null) { + String id = row.getIdText(); + if (id != null) { + id = id.trim(); + if (!id.isEmpty()) { + Identifier parsedId = Identifier.tryParse(id); + if (parsedId != null) { + String rawChance = row.getChanceText() == null ? "" : row.getChanceText().trim(); + int min = this.parseInt(row.getMinText(), 1); + int max = this.parseInt(row.getMaxText(), min); + if (max < min) { + max = min; + } + + String birthEntity = row.getBirthEntityText(); + if (birthEntity != null && !birthEntity.trim().isEmpty()) { + birthEntity = birthEntity.trim(); + } else { + birthEntity = id; + } + + if (Identifier.tryParse(birthEntity) == null) { + birthEntity = id; + } + + String birthMode = NonConfig.normalizeBirthMode(row.getBirthModeText()); + if (birthMode == null) { + birthMode = "direct"; + } + + NonConfig.EggProfile eggProfile = row.getEggProfile(); + NonConfig.EntityProfile existing = this.config.getEntityProfilesByEntity().get(id); + NonConfig.EntityProfile defaultProfile = NonEntityProfiles.resolveDefaultProfile(parsedId).toConfigProfile(); + int defaultChance = defaultProfile.pregnancyChancePercent() == null + ? this.config.getPregnancyChancePercent() + : defaultProfile.pregnancyChancePercent(); + Integer chanceOverride = rawChance.isEmpty() ? null : Math.max(0, Math.min(100, this.parseInt(rawChance, defaultChance))); + Integer minOverride = min == defaultProfile.offspringMin() ? null : min; + Integer maxOverride = max == defaultProfile.offspringMax() ? null : max; + String birthEntityOverride = birthEntity.equals(defaultProfile.birthEntity()) ? null : birthEntity; + String birthModeOverride = birthMode.equals(defaultProfile.birthMode()) ? null : birthMode; + NonConfig.EggProfile defaultEgg = defaultProfile.egg() == null ? NonConfig.EggProfile.defaults() : defaultProfile.egg(); + NonConfig.EggProfile eggOverride = Objects.equals(eggProfile, defaultEgg) ? null : eggProfile; + NonConfig.EntityProfile next = new NonConfig.EntityProfile( + chanceOverride != null && chanceOverride != defaultChance ? chanceOverride : null, + minOverride, + maxOverride, + birthEntityOverride, + birthModeOverride, + existing == null ? null : existing.genderSpawn(), + eggOverride, + existing == null ? null : existing.lookAtNearbyAnimations(), + existing == null ? null : existing.multiActorJoinChancePercent(), + existing == null ? null : existing.energyGainMultiplier() + ) + .sanitized(); + if (!next.isEmpty()) { + map.put(id, next); + } + } + } + } + } + } + + return map; + } + + private String chanceSuggestionFor(String idText) { + Identifier id = idText != null && !idText.isBlank() ? Identifier.tryParse(idText.trim()) : null; + int defaultChance = id == null ? this.config.getPregnancyChancePercent() : NonEntityProfiles.resolveDefaultProfile(id).pregnancyChancePercent(); + return String.valueOf(defaultChance); + } + + private List snapshotRows() { + if (this.rows.isEmpty()) { + return null; + } + + List data = new ArrayList<>(); + + for (NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry row : this.rows) { + data.add( + new NonModMenuDebugScreens.OffspringCountConfigScreen.RowData( + row.getIdText(), + row.getChanceText(), + row.getMinText(), + row.getMaxText(), + row.getBirthEntityText(), + row.getBirthModeText(), + row.getEggProfile() + ) + ); + } + + return data; + } + + private int parseInt(String value, int fallback) { + if (value == null) { + return fallback; + } + + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private void openEggSettings(NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry row) { + if (row != null) { + Identifier id = Identifier.tryParse(row.getIdText() == null ? "" : row.getIdText().trim()); + NonConfig.EggProfile defaultEgg = id == null ? NonConfig.EggProfile.defaults() : NonEntityProfiles.resolveDefaultProfile(id).eggProfile(); + MinecraftClient.getInstance() + .setScreen(new NonModMenuDebugScreens.OffspringCountConfigScreen.EggProfileSettingsScreen(this, row.getEggProfile(), defaultEgg, updated -> { + row.setEggProfile(updated); + this.updateApplyButton(); + }, this.serverConfigEditable)); + } + } + + private static final class EggProfileSettingsScreen extends Screen { + private final Screen parent; + private final NonConfig.EggProfile initialProfile; + private final NonConfig.EggProfile defaultProfile; + private final Consumer onApply; + private final boolean editable; + private TextFieldWidget startSizeField; + private TextFieldWidget endSizeField; + private TextFieldWidget textureField; + private TextFieldWidget healthField; + + private EggProfileSettingsScreen( + Screen parent, NonConfig.EggProfile initialProfile, NonConfig.EggProfile defaultProfile, Consumer onApply, boolean editable + ) { + super(Text.translatable("config.needsofnature.egg_profile_title")); + this.parent = parent; + this.initialProfile = initialProfile == null ? NonConfig.EggProfile.defaults() : initialProfile.sanitized(); + this.defaultProfile = defaultProfile == null ? NonConfig.EggProfile.defaults() : defaultProfile.sanitized(); + this.onApply = onApply; + this.editable = editable; + } + + protected void init() { + int centerX = this.width / 2; + int labelX = centerX - 180; + int fieldX = centerX - 40; + int y = 58; + int textureFieldW = Math.min(460, Math.max(220, this.width - fieldX - 20)); + this.startSizeField = this.newTextField(70, formatFloat(valueOrDefault(this.initialProfile.startSize(), this.defaultProfile.startSize(), 0.5F))); + this.endSizeField = this.newTextField(70, formatFloat(valueOrDefault(this.initialProfile.endSize(), this.defaultProfile.endSize(), 1.0F))); + this.textureField = this.newTextField(textureFieldW, valueOrDefault(this.initialProfile.texture(), this.defaultProfile.texture(), "")); + this.healthField = this.newTextField(70, formatFloat(valueOrDefault(this.initialProfile.health(), this.defaultProfile.health(), 2.0F))); + this.addField(this.startSizeField, fieldX, y, "config.needsofnature.tooltip.egg_profile_start_size"); + y += 28; + this.addField(this.endSizeField, fieldX, y, "config.needsofnature.tooltip.egg_profile_end_size"); + y += 28; + this.addField(this.textureField, fieldX, y, "config.needsofnature.tooltip.egg_profile_texture"); + y += 28; + this.addField(this.healthField, fieldX, y, "config.needsofnature.tooltip.egg_profile_health"); + ButtonWidget resetButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset"), button -> { + this.startSizeField.setText(formatFloat(valueOrDefault(this.defaultProfile.startSize(), null, 0.5F))); + this.endSizeField.setText(formatFloat(valueOrDefault(this.defaultProfile.endSize(), null, 1.0F))); + this.textureField.setText(valueOrDefault(this.defaultProfile.texture(), null, "")); + this.healthField.setText(formatFloat(valueOrDefault(this.defaultProfile.health(), null, 2.0F))); + }).dimensions(centerX - 154, this.height - 52, 96, 20).build(); + resetButton.active = this.editable; + NonModMenuScreens.setTooltip(resetButton, "config.needsofnature.tooltip.egg_profile_reset"); + this.addDrawableChild(resetButton); + ButtonWidget applyButton = ButtonWidget.builder(Text.translatable("config.needsofnature.apply"), button -> { + if (this.editable) { + if (this.onApply != null) { + this.onApply.accept(this.collectProfile()); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + }).dimensions(centerX - 50, this.height - 52, 96, 20).build(); + applyButton.active = this.editable; + NonModMenuScreens.setTooltip(applyButton, "config.needsofnature.tooltip.apply"); + this.addDrawableChild(applyButton); + ButtonWidget cancelButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 54, this.height - 52, 96, 20) + .build(); + this.addDrawableChild(cancelButton); + this.startSizeField.setEditable(this.editable); + this.endSizeField.setEditable(this.editable); + this.textureField.setEditable(this.editable); + this.healthField.setEditable(this.editable); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + int centerX = this.width / 2; + int labelX = centerX - 180; + int y = 63; + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 24, -1); + this.drawLabel(context, "config.needsofnature.egg_profile_start_size", labelX, y); + y += 28; + this.drawLabel(context, "config.needsofnature.egg_profile_end_size", labelX, y); + y += 28; + this.drawLabel(context, "config.needsofnature.egg_profile_texture", labelX, y); + y += 28; + this.drawLabel(context, "config.needsofnature.egg_profile_health", labelX, y); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void addField(TextFieldWidget field, int x, int y, String tooltipKey) { + field.setX(x); + field.setY(y); + NonModMenuScreens.setTooltip(field, tooltipKey); + this.addDrawableChild(field); + } + + private TextFieldWidget newTextField(int width, String value) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, width, 20, Text.literal("")); + field.setMaxLength(128); + field.setText(value == null ? "" : value); + return field; + } + + private void drawLabel(DrawContext context, String key, int x, int y) { + context.drawTextWithShadow(this.textRenderer, Text.translatable(key), x, y, -2039584); + } + + private NonConfig.EggProfile collectProfile() { + NonConfig.EggProfile raw = new NonConfig.EggProfile( + parseFloat(this.startSizeField.getText(), this.defaultProfile.startSize(), 0.5F), + parseFloat(this.endSizeField.getText(), this.defaultProfile.endSize(), 1.0F), + this.textureField.getText() != null && !this.textureField.getText().trim().isEmpty() ? this.textureField.getText().trim() : null, + parseFloat(this.healthField.getText(), this.defaultProfile.health(), 2.0F) + ); + return raw.resolvedOver(this.defaultProfile); + } + + private static Float parseFloat(String raw, Float fallback, float hardFallback) { + if (raw != null && !raw.trim().isEmpty()) { + try { + return Float.parseFloat(raw.trim()); + } catch (NumberFormatException e) { + return fallback == null ? hardFallback : fallback; + } + } else { + return fallback == null ? hardFallback : fallback; + } + } + + private static float valueOrDefault(Float value, Float fallback, float hardFallback) { + if (value != null) { + return value; + } else { + return fallback != null ? fallback : hardFallback; + } + } + + private static String valueOrDefault(String value, String fallback, String hardFallback) { + if (value != null && !value.isBlank()) { + return value; + } else { + return fallback != null && !fallback.isBlank() ? fallback : hardFallback; + } + } + + private static String formatFloat(float value) { + String formatted = String.format(Locale.ROOT, "%.3f", value); + + while (formatted.contains(".") && formatted.endsWith("0")) { + formatted = formatted.substring(0, formatted.length() - 1); + } + + if (formatted.endsWith(".")) { + formatted = formatted.substring(0, formatted.length() - 1); + } + + return formatted; + } + } + + private static final class OffspringCountList + extends ElementListWidget { + private OffspringCountList(MinecraftClient client, int width, int height, int top) { + super(client, width, height, top, 24); + this.centerListVertically = false; + } + + private void addEntryRow(NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry entry) { + super.addEntry(entry); + } + + private void removeEntryRow(NonModMenuDebugScreens.OffspringCountConfigScreen.OffspringCountList.OffspringEntry entry) { + super.removeEntry(entry); + } + + public int getRowWidth() { + return 620; + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + private static final class OffspringEntry + extends net.minecraft.client.gui.widget.ElementListWidget.Entry { + private final TextFieldWidget idField; + private final TextFieldWidget chanceField; + private final TextFieldWidget minField; + private final TextFieldWidget maxField; + private final TextFieldWidget birthEntityField; + private final TextFieldWidget birthModeField; + private final ButtonWidget eggButton; + private final ButtonWidget removeButton; + private final List widgets; + private final Function chanceSuggestionSupplier; + private NonConfig.EggProfile eggProfile; + private boolean editable; + private Runnable onChange; + + private OffspringEntry( + TextRenderer textRenderer, + String idText, + String chanceText, + String minText, + String maxText, + String birthEntityText, + String birthModeText, + NonConfig.EggProfile eggProfile, + Runnable onRemove, + Function chanceSuggestionSupplier, + Consumer onEggSettings + ) { + this.chanceSuggestionSupplier = chanceSuggestionSupplier; + this.eggProfile = eggProfile == null ? NonConfig.EggProfile.defaults() : eggProfile.sanitized(); + this.idField = new TextFieldWidget(textRenderer, 0, 0, 120, 20, Text.literal("")); + this.idField.setText(idText == null ? "" : idText); + this.idField.setMaxLength(80); + NonModMenuScreens.setTooltip(this.idField, "config.needsofnature.tooltip.offspring_count_entry_entity"); + this.chanceField = new TextFieldWidget(textRenderer, 0, 0, 44, 20, Text.literal("")); + this.chanceField.setText(chanceText == null ? "" : chanceText); + this.chanceField.setMaxLength(3); + this.updateChanceSuggestion(); + NonModMenuScreens.setTooltip(this.chanceField, "config.needsofnature.tooltip.entity_profile_entry_chance"); + this.minField = new TextFieldWidget(textRenderer, 0, 0, 34, 20, Text.literal("")); + this.minField.setText(minText == null ? "1" : minText); + this.minField.setMaxLength(3); + NonModMenuScreens.setTooltip(this.minField, "config.needsofnature.tooltip.offspring_count_entry_min"); + this.maxField = new TextFieldWidget(textRenderer, 0, 0, 34, 20, Text.literal("")); + this.maxField.setText(maxText == null ? "1" : maxText); + this.maxField.setMaxLength(3); + NonModMenuScreens.setTooltip(this.maxField, "config.needsofnature.tooltip.offspring_count_entry_max"); + this.birthEntityField = new TextFieldWidget(textRenderer, 0, 0, 140, 20, Text.literal("")); + this.birthEntityField.setText(birthEntityText == null ? "" : birthEntityText); + this.birthEntityField.setMaxLength(80); + NonModMenuScreens.setTooltip(this.birthEntityField, "config.needsofnature.tooltip.entity_profile_entry_birth_entity"); + this.birthModeField = new TextFieldWidget(textRenderer, 0, 0, 54, 20, Text.literal("")); + this.birthModeField.setText(birthModeText == null ? "direct" : birthModeText); + this.birthModeField.setMaxLength(6); + NonModMenuScreens.setTooltip(this.birthModeField, "config.needsofnature.tooltip.entity_profile_entry_birth_mode"); + this.eggButton = ButtonWidget.builder(Text.translatable("config.needsofnature.entity_profile_egg_button"), button -> { + if (onEggSettings != null) { + onEggSettings.accept(this); + } + }).dimensions(0, 0, 42, 20).build(); + NonModMenuScreens.setTooltip(this.eggButton, "config.needsofnature.tooltip.entity_profile_entry_egg"); + this.removeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.remove_icon"), button -> { + if (onRemove != null) { + onRemove.run(); + } + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(this.removeButton, "config.needsofnature.tooltip.remove"); + this.widgets = new ArrayList<>(8); + this.widgets.add(this.idField); + this.widgets.add(this.chanceField); + this.widgets.add(this.minField); + this.widgets.add(this.maxField); + this.widgets.add(this.birthEntityField); + this.widgets.add(this.birthModeField); + this.widgets.add(this.eggButton); + this.widgets.add(this.removeButton); + this.idField.setChangedListener(ignored -> { + this.updateChanceSuggestion(); + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.chanceField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.minField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.maxField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.birthEntityField.setChangedListener(ignored -> { + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.birthModeField.setChangedListener(ignored -> { + this.updateEggButtonActive(); + if (this.onChange != null) { + this.onChange.run(); + } + }); + this.updateEggButtonActive(); + } + + private void setOnChange(Runnable onChange) { + this.onChange = onChange; + } + + private void updateChanceSuggestion() { + String suggestion = this.chanceSuggestionSupplier == null ? null : this.chanceSuggestionSupplier.apply(this.idField.getText()); + this.chanceField.setPlaceholder(suggestion != null && !suggestion.isBlank() ? Text.literal(suggestion) : null); + } + + private void setEditable(boolean editable) { + this.editable = editable; + this.idField.setEditable(editable); + this.chanceField.setEditable(editable); + this.minField.setEditable(editable); + this.maxField.setEditable(editable); + this.birthEntityField.setEditable(editable); + this.birthModeField.setEditable(editable); + this.updateEggButtonActive(); + this.removeButton.active = editable; + } + + private void updateEggButtonActive() { + this.eggButton.active = this.editable && "egg".equals(NonConfig.normalizeBirthMode(this.birthModeField.getText())); + } + + private String getIdText() { + return this.idField.getText(); + } + + private String getMinText() { + return this.minField.getText(); + } + + private String getMaxText() { + return this.maxField.getText(); + } + + private String getChanceText() { + return this.chanceField.getText(); + } + + private String getBirthEntityText() { + return this.birthEntityField.getText(); + } + + private String getBirthModeText() { + return this.birthModeField.getText(); + } + + private NonConfig.EggProfile getEggProfile() { + return this.eggProfile == null ? NonConfig.EggProfile.defaults() : this.eggProfile.sanitized(); + } + + private void setEggProfile(NonConfig.EggProfile eggProfile) { + this.eggProfile = eggProfile == null ? NonConfig.EggProfile.defaults() : eggProfile.sanitized(); + if (this.onChange != null) { + this.onChange.run(); + } + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int rowX = this.getContentX(); + int rowY = this.getContentY(); + int rowWidth = this.getContentWidth(); + int widgetY = rowY + 2; + int removeW = this.removeButton.getWidth(); + int chanceW = this.chanceField.getWidth(); + int minW = this.minField.getWidth(); + int maxW = this.maxField.getWidth(); + int birthW = this.birthEntityField.getWidth(); + int modeW = this.birthModeField.getWidth(); + int eggW = this.eggButton.getWidth(); + int idW = Math.max(105, rowWidth - removeW - chanceW - minW - maxW - birthW - modeW - eggW - 48); + int chanceX = rowX + idW + 6; + int minX = chanceX + chanceW + 6; + int maxX = minX + minW + 6; + int birthX = maxX + maxW + 6; + int modeX = birthX + birthW + 6; + int eggX = modeX + modeW + 6; + int removeX = eggX + eggW + 6; + this.idField.setX(rowX); + this.idField.setY(widgetY); + this.idField.setWidth(idW); + this.chanceField.setX(chanceX); + this.chanceField.setY(widgetY); + this.minField.setX(minX); + this.minField.setY(widgetY); + this.maxField.setX(maxX); + this.maxField.setY(widgetY); + this.birthEntityField.setX(birthX); + this.birthEntityField.setY(widgetY); + this.birthModeField.setX(modeX); + this.birthModeField.setY(widgetY); + this.eggButton.setX(eggX); + this.eggButton.setY(widgetY); + this.removeButton.setX(removeX); + this.removeButton.setY(widgetY); + + for (ClickableWidget widget : this.widgets) { + widget.render(context, mouseX, mouseY, deltaTicks); + } + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + } + } + + private static final class ResetOffspringCountConfirmScreen extends Screen { + private final Screen parent; + private final Runnable onConfirm; + + private ResetOffspringCountConfirmScreen(Screen parent, Runnable onConfirm) { + super(Text.translatable("config.needsofnature.offspring_count_reset_title")); + this.parent = parent; + this.onConfirm = onConfirm; + } + + protected void init() { + int centerX = this.width / 2; + int buttonY = this.height / 2 + 20; + ButtonWidget yesButton = ButtonWidget.builder(Text.translatable("gui.yes"), button -> { + if (this.onConfirm != null) { + this.onConfirm.run(); + } + + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX - 100, buttonY, 96, 20).build(); + this.addDrawableChild(yesButton); + ButtonWidget noButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 4, buttonY, 96, 20) + .build(); + this.addDrawableChild(noButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, this.height / 2 - 20, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.offspring_count_reset_body"), this.width / 2, this.height / 2 - 4, -4144960 + ); + } + } + + private record RowData(String id, String chance, String min, String max, String birthEntity, String birthMode, NonConfig.EggProfile eggProfile) { + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuExternalScreens.java b/src/main/java/com/nonid/integration/NonModMenuExternalScreens.java new file mode 100644 index 0000000..948020b --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuExternalScreens.java @@ -0,0 +1,17 @@ +package com.nonid.integration; + +import com.nonid.NeedsOfNature; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; + +public final class NonModMenuExternalScreens { + private NonModMenuExternalScreens() { + } + + public static void openPlayerPreferences(Screen parent) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + client.setScreen(new NonModMenuPlayerScreens.PlayerPreferencesScreen(parent, NeedsOfNature.getLocalConfig())); + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuGameplayScreens.java b/src/main/java/com/nonid/integration/NonModMenuGameplayScreens.java new file mode 100644 index 0000000..d67bfe3 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuGameplayScreens.java @@ -0,0 +1,2383 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import com.nonid.client.NonHudOverlay; +import java.util.Locale; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +final class NonModMenuGameplayScreens { + private NonModMenuGameplayScreens() { + } + + static class GameplayAnimationsScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int loopSeconds; + private int peakLoopSeconds; + private int maleMalePercent; + private int femaleFemalePercent; + private int entityEntityPercent; + private int multiActorJoinPercent; + private TextFieldWidget loopField; + private TextFieldWidget peakLoopField; + private TextFieldWidget maleMaleField; + private TextFieldWidget femaleFemaleField; + private TextFieldWidget entityEntityField; + private TextFieldWidget multiActorJoinField; + private ButtonWidget resetLoopButton; + private ButtonWidget resetPeakLoopButton; + private ButtonWidget resetMaleMaleButton; + private ButtonWidget resetFemaleFemaleButton; + private ButtonWidget resetEntityEntityButton; + private ButtonWidget resetMultiActorJoinButton; + private boolean serverConfigEditable; + + protected GameplayAnimationsScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.section.animations")); + this.parent = parent; + this.config = config; + this.loopSeconds = NonHudOverlay.getRuntimeLoopSeconds(); + this.peakLoopSeconds = NonHudOverlay.getRuntimePeakLoopSeconds(); + this.maleMalePercent = config.getMaleMaleChancePercent(); + this.femaleFemalePercent = config.getFemaleFemaleChancePercent(); + this.entityEntityPercent = config.getEntityEntityChancePercent(); + this.multiActorJoinPercent = config.getMultiActorJoinChancePercent(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.loopField != null) { + this.loopSeconds = this.parseField(this.loopField, this.loopSeconds); + this.peakLoopSeconds = this.parseField(this.peakLoopField, this.peakLoopSeconds); + this.maleMalePercent = this.parseField(this.maleMaleField, this.maleMalePercent); + this.femaleFemalePercent = this.parseField(this.femaleFemaleField, this.femaleFemalePercent); + this.entityEntityPercent = this.parseField(this.entityEntityField, this.entityEntityPercent); + this.multiActorJoinPercent = this.parseField(this.multiActorJoinField, this.multiActorJoinPercent); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.animation_pacing"))); + this.loopField = this.newNumberField(fieldWidth, this.loopSeconds); + this.resetLoopButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), button -> this.loopField.setText(String.valueOf(this.defaults.getLoopProgressSeconds())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLoopButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.loop_seconds"), + this.loopField, + this.resetLoopButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.loop_seconds") + ) + ); + this.peakLoopField = this.newNumberField(fieldWidth, this.peakLoopSeconds); + this.resetPeakLoopButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.peakLoopField.setText(String.valueOf(this.defaults.getPeakLoopProgressSeconds())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPeakLoopButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.peak_loop_seconds"), + this.peakLoopField, + this.resetPeakLoopButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.peak_loop_seconds") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.match_chances"))); + this.maleMaleField = this.newNumberField(fieldWidth, this.maleMalePercent); + this.resetMaleMaleButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.maleMaleField.setText(String.valueOf(this.defaults.getMaleMaleChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetMaleMaleButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.male_male_chance"), + this.maleMaleField, + this.resetMaleMaleButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.male_male_chance") + ) + ); + this.femaleFemaleField = this.newNumberField(fieldWidth, this.femaleFemalePercent); + this.resetFemaleFemaleButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.femaleFemaleField.setText(String.valueOf(this.defaults.getFemaleFemaleChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFemaleFemaleButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.female_female_chance"), + this.femaleFemaleField, + this.resetFemaleFemaleButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.female_female_chance") + ) + ); + this.entityEntityField = this.newNumberField(fieldWidth, this.entityEntityPercent); + this.resetEntityEntityButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.entityEntityField.setText(String.valueOf(this.defaults.getEntityEntityChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetEntityEntityButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.entity_entity_chance"), + this.entityEntityField, + this.resetEntityEntityButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.entity_entity_chance") + ) + ); + this.multiActorJoinField = this.newNumberField(fieldWidth, this.multiActorJoinPercent); + this.resetMultiActorJoinButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.multiActorJoinField.setText(String.valueOf(this.defaults.getMultiActorJoinChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetMultiActorJoinButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.multi_actor_join_chance"), + this.multiActorJoinField, + this.resetMultiActorJoinButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.multi_actor_join_chance") + ) + ); + this.loopField.setChangedListener(ignored -> this.updateResetButtons()); + this.peakLoopField.setChangedListener(ignored -> this.updateResetButtons()); + this.maleMaleField.setChangedListener(ignored -> this.updateResetButtons()); + this.femaleFemaleField.setChangedListener(ignored -> this.updateResetButtons()); + this.entityEntityField.setChangedListener(ignored -> this.updateResetButtons()); + this.multiActorJoinField.setChangedListener(ignored -> this.updateResetButtons()); + this.loopField.setEditable(this.serverConfigEditable); + this.peakLoopField.setEditable(this.serverConfigEditable); + this.maleMaleField.setEditable(this.serverConfigEditable); + this.femaleFemaleField.setEditable(this.serverConfigEditable); + this.entityEntityField.setEditable(this.serverConfigEditable); + this.multiActorJoinField.setEditable(this.serverConfigEditable); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.loopSeconds = this.clampLoopSeconds(this.parseField(this.loopField, this.loopSeconds)); + this.peakLoopSeconds = this.clampPeakLoopSeconds(this.parseField(this.peakLoopField, this.peakLoopSeconds)); + this.maleMalePercent = this.clampPercent(this.parseField(this.maleMaleField, this.maleMalePercent)); + this.femaleFemalePercent = this.clampPercent(this.parseField(this.femaleFemaleField, this.femaleFemalePercent)); + this.entityEntityPercent = this.clampPercent(this.parseField(this.entityEntityField, this.entityEntityPercent)); + this.multiActorJoinPercent = this.clampPercent(this.parseField(this.multiActorJoinField, this.multiActorJoinPercent)); + this.config.setLoopProgressSeconds(this.loopSeconds); + this.config.setPeakLoopProgressSeconds(this.peakLoopSeconds); + this.config.setMaleMaleChancePercent(this.maleMalePercent); + this.config.setFemaleFemaleChancePercent(this.femaleFemalePercent); + this.config.setEntityEntityChancePercent(this.entityEntityPercent); + this.config.setMultiActorJoinChancePercent(this.multiActorJoinPercent); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int clampLoopSeconds(int v) { + return Math.max(5, Math.min(300, v)); + } + + private int clampPeakLoopSeconds(int v) { + return Math.max(1, Math.min(300, v)); + } + + private int clampPercent(int v) { + return Math.max(0, Math.min(100, v)); + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseField(field, defaultValue) != defaultValue; + } + + private void updateResetButtons() { + if (this.resetLoopButton != null) { + this.resetLoopButton.active = this.serverConfigEditable && this.isIntFieldChanged(this.loopField, this.defaults.getLoopProgressSeconds()); + } + + if (this.resetPeakLoopButton != null) { + this.resetPeakLoopButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.peakLoopField, this.defaults.getPeakLoopProgressSeconds()); + } + + if (this.resetMaleMaleButton != null) { + this.resetMaleMaleButton.active = this.serverConfigEditable && this.isIntFieldChanged(this.maleMaleField, this.defaults.getMaleMaleChancePercent()); + } + + if (this.resetFemaleFemaleButton != null) { + this.resetFemaleFemaleButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.femaleFemaleField, this.defaults.getFemaleFemaleChancePercent()); + } + + if (this.resetEntityEntityButton != null) { + this.resetEntityEntityButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.entityEntityField, this.defaults.getEntityEntityChancePercent()); + } + + if (this.resetMultiActorJoinButton != null) { + this.resetMultiActorJoinButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.multiActorJoinField, this.defaults.getMultiActorJoinChancePercent()); + } + } + } + + static class GameplayCategoryScreen extends Screen { + private final Screen parent; + private final NonConfig config; + + protected GameplayCategoryScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.gameplay_title")); + this.parent = parent; + this.config = config; + } + + protected void init() { + int centerX = this.width / 2; + int y = this.height / 2 - 48; + int gap = 24; + ButtonWidget worldButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.section.world"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuGameplayScreens.GameplayWorldScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + this.addDrawableChild(worldButton); + y += gap; + ButtonWidget energyButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.section.energy"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuGameplayScreens.GameplayEnergyScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + this.addDrawableChild(energyButton); + y += gap; + ButtonWidget animationsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.section.animations"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuGameplayScreens.GameplayAnimationsScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + this.addDrawableChild(animationsButton); + y += gap; + ButtonWidget difficultyButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.section.difficulty"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuGameplayScreens.GameplayDifficultyScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + this.addDrawableChild(difficultyButton); + ButtonWidget doneButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent) + ) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + } + + static class GameplayDifficultyScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int playerRequestMinEnergy; + private int attackHits; + private double attackDecay; + private int attackEscapeDamageDifficultyPercent; + private int pvpFullHealthEscapeBonusPercent; + private int pvpCriticalHealthMashStrengthPercent; + private int pvpMaxHeatEscapeStrengthPercent; + private int pvpZeroHeatAttackStrengthPercent; + private int attackEscapeInvulnerabilitySeconds; + private int attackEscapeAnimationProtectionSeconds; + private int postEscapeGatherMaxMobs; + private boolean attackCreativePlayers; + private boolean blockAnimationsWhileRidingLivingEntities; + private boolean blockAnimationsWhileRidingVehicles; + private int attackOutcomeFailsafeThreshold; + private boolean lastDefeatedEnabled; + private int lastDefeatedEnergyThreshold; + private int lastDefeatedSearchRadius; + private int lastDefeatedCooldownSeconds; + private boolean allowCraftingTableSkinRepair; + private boolean keepMessAfterDeath; + private boolean keepRippedSkinAfterDeath; + private boolean keepLiquidTankAfterDeath; + private boolean keepPregnancyAfterDeath; + private TextFieldWidget playerRequestMinEnergyField; + private TextFieldWidget attackHitsField; + private TextFieldWidget attackDecayField; + private TextFieldWidget attackEscapeDamageDifficultyPercentField; + private TextFieldWidget pvpFullHealthEscapeBonusPercentField; + private TextFieldWidget pvpCriticalHealthMashStrengthPercentField; + private TextFieldWidget pvpMaxHeatEscapeStrengthPercentField; + private TextFieldWidget pvpZeroHeatAttackStrengthPercentField; + private TextFieldWidget attackEscapeInvulnerabilitySecondsField; + private TextFieldWidget attackEscapeAnimationProtectionSecondsField; + private TextFieldWidget postEscapeGatherMaxMobsField; + private TextFieldWidget attackOutcomeFailsafeThresholdField; + private TextFieldWidget lastDefeatedEnergyThresholdField; + private TextFieldWidget lastDefeatedSearchRadiusField; + private TextFieldWidget lastDefeatedCooldownSecondsField; + private ButtonWidget resetPlayerRequestMinEnergyButton; + private ButtonWidget resetAttackHitsButton; + private ButtonWidget resetAttackDecayButton; + private ButtonWidget resetAttackEscapeDamageDifficultyPercentButton; + private ButtonWidget resetPvpFullHealthEscapeBonusPercentButton; + private ButtonWidget resetPvpCriticalHealthMashStrengthPercentButton; + private ButtonWidget resetPvpMaxHeatEscapeStrengthPercentButton; + private ButtonWidget resetPvpZeroHeatAttackStrengthPercentButton; + private ButtonWidget resetAttackEscapeInvulnerabilitySecondsButton; + private ButtonWidget resetAttackEscapeAnimationProtectionSecondsButton; + private ButtonWidget resetPostEscapeGatherMaxMobsButton; + private ButtonWidget attackCreativePlayersButton; + private ButtonWidget resetAttackCreativePlayersButton; + private ButtonWidget blockAnimationsWhileRidingLivingEntitiesButton; + private ButtonWidget resetBlockAnimationsWhileRidingLivingEntitiesButton; + private ButtonWidget blockAnimationsWhileRidingVehiclesButton; + private ButtonWidget resetBlockAnimationsWhileRidingVehiclesButton; + private ButtonWidget resetAttackOutcomeFailsafeThresholdButton; + private ButtonWidget lastDefeatedEnabledButton; + private ButtonWidget resetLastDefeatedEnabledButton; + private ButtonWidget resetLastDefeatedEnergyThresholdButton; + private ButtonWidget resetLastDefeatedSearchRadiusButton; + private ButtonWidget resetLastDefeatedCooldownSecondsButton; + private ButtonWidget allowCraftingTableSkinRepairButton; + private ButtonWidget resetAllowCraftingTableSkinRepairButton; + private ButtonWidget keepMessAfterDeathButton; + private ButtonWidget resetKeepMessAfterDeathButton; + private ButtonWidget keepRippedSkinAfterDeathButton; + private ButtonWidget resetKeepRippedSkinAfterDeathButton; + private ButtonWidget keepLiquidTankAfterDeathButton; + private ButtonWidget resetKeepLiquidTankAfterDeathButton; + private ButtonWidget keepPregnancyAfterDeathButton; + private ButtonWidget resetKeepPregnancyAfterDeathButton; + private boolean serverConfigEditable; + + protected GameplayDifficultyScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.section.difficulty")); + this.parent = parent; + this.config = config; + this.playerRequestMinEnergy = config.getPlayerRequestMinEnergy(); + this.attackHits = NonHudOverlay.getRuntimeAttackEscapeHits(); + this.attackDecay = NonHudOverlay.getRuntimeAttackDecayPerSecond(); + this.attackEscapeDamageDifficultyPercent = NonHudOverlay.getRuntimeAttackEscapeDamageDifficultyPercent(); + this.pvpFullHealthEscapeBonusPercent = config.getPvpFullHealthEscapeBonusPercent(); + this.pvpCriticalHealthMashStrengthPercent = config.getPvpCriticalHealthMashStrengthPercent(); + this.pvpMaxHeatEscapeStrengthPercent = config.getPvpMaxHeatEscapeStrengthPercent(); + this.pvpZeroHeatAttackStrengthPercent = config.getPvpZeroHeatAttackStrengthPercent(); + this.attackEscapeInvulnerabilitySeconds = config.getAttackEscapeInvulnerabilitySeconds(); + this.attackEscapeAnimationProtectionSeconds = config.getAttackEscapeAnimationProtectionSeconds(); + this.postEscapeGatherMaxMobs = config.getPostEscapeGatherMaxMobs(); + this.attackCreativePlayers = NonHudOverlay.getRuntimeAttackCreativePlayers(); + this.blockAnimationsWhileRidingLivingEntities = config.blockAnimationsWhileRidingLivingEntities(); + this.blockAnimationsWhileRidingVehicles = config.blockAnimationsWhileRidingVehicles(); + this.attackOutcomeFailsafeThreshold = config.getAttackOutcomeFailsafeThreshold(); + this.lastDefeatedEnabled = config.isLastDefeatedEnabled(); + this.lastDefeatedEnergyThreshold = config.getLastDefeatedEnergyThreshold(); + this.lastDefeatedSearchRadius = config.getLastDefeatedSearchRadius(); + this.lastDefeatedCooldownSeconds = config.getLastDefeatedCooldownSeconds(); + this.allowCraftingTableSkinRepair = config.isCraftingTableSkinRepairAllowed(); + this.keepMessAfterDeath = config.keepMessAfterDeath(); + this.keepRippedSkinAfterDeath = config.keepRippedSkinAfterDeath(); + this.keepLiquidTankAfterDeath = config.keepLiquidTankAfterDeath(); + this.keepPregnancyAfterDeath = config.keepPregnancyAfterDeath(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.attackHitsField != null) { + this.playerRequestMinEnergy = this.parseField(this.playerRequestMinEnergyField, this.playerRequestMinEnergy); + this.attackHits = this.parseField(this.attackHitsField, this.attackHits); + this.attackDecay = this.parseDecimalField(this.attackDecayField, this.attackDecay); + this.attackEscapeDamageDifficultyPercent = this.parseField(this.attackEscapeDamageDifficultyPercentField, this.attackEscapeDamageDifficultyPercent); + this.pvpFullHealthEscapeBonusPercent = this.parseField(this.pvpFullHealthEscapeBonusPercentField, this.pvpFullHealthEscapeBonusPercent); + this.pvpCriticalHealthMashStrengthPercent = this.parseField( + this.pvpCriticalHealthMashStrengthPercentField, this.pvpCriticalHealthMashStrengthPercent + ); + this.pvpMaxHeatEscapeStrengthPercent = this.parseField(this.pvpMaxHeatEscapeStrengthPercentField, this.pvpMaxHeatEscapeStrengthPercent); + this.pvpZeroHeatAttackStrengthPercent = this.parseField(this.pvpZeroHeatAttackStrengthPercentField, this.pvpZeroHeatAttackStrengthPercent); + this.attackEscapeInvulnerabilitySeconds = this.parseField(this.attackEscapeInvulnerabilitySecondsField, this.attackEscapeInvulnerabilitySeconds); + this.attackEscapeAnimationProtectionSeconds = this.parseField( + this.attackEscapeAnimationProtectionSecondsField, this.attackEscapeAnimationProtectionSeconds + ); + this.postEscapeGatherMaxMobs = this.parseField(this.postEscapeGatherMaxMobsField, this.postEscapeGatherMaxMobs); + this.attackOutcomeFailsafeThreshold = this.parseField(this.attackOutcomeFailsafeThresholdField, this.attackOutcomeFailsafeThreshold); + this.lastDefeatedEnergyThreshold = this.parseField(this.lastDefeatedEnergyThresholdField, this.lastDefeatedEnergyThreshold); + this.lastDefeatedSearchRadius = this.parseField(this.lastDefeatedSearchRadiusField, this.lastDefeatedSearchRadius); + this.lastDefeatedCooldownSeconds = this.parseField(this.lastDefeatedCooldownSecondsField, this.lastDefeatedCooldownSeconds); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.player_requests"))); + this.playerRequestMinEnergyField = this.newNumberField(fieldWidth, this.playerRequestMinEnergy); + this.resetPlayerRequestMinEnergyButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerRequestMinEnergyField.setText(String.valueOf(this.defaults.getPlayerRequestMinEnergy())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerRequestMinEnergyButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_request_min_energy"), + this.playerRequestMinEnergyField, + this.resetPlayerRequestMinEnergyButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_request_min_energy") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.attack_escape"))); + this.attackCreativePlayersButton = ButtonWidget.builder(this.onOffLabel(this.attackCreativePlayers), button -> { + this.attackCreativePlayers = !this.attackCreativePlayers; + button.setMessage(this.onOffLabel(this.attackCreativePlayers)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetAttackCreativePlayersButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.attackCreativePlayers = this.defaults.canAttackCreativePlayers(); + this.attackCreativePlayersButton.setMessage(this.onOffLabel(this.attackCreativePlayers)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetAttackCreativePlayersButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_creative_players"), + this.attackCreativePlayersButton, + this.resetAttackCreativePlayersButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_creative_players") + ) + ); + this.attackHitsField = this.newNumberField(fieldWidth, this.attackHits); + this.resetAttackHitsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackHitsField.setText(String.valueOf(this.defaults.getAttackEscapeHits())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackHitsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_hits"), + this.attackHitsField, + this.resetAttackHitsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_hits") + ) + ); + this.attackDecayField = this.newDecimalField(fieldWidth, this.attackDecay); + this.resetAttackDecayButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackDecayField.setText(String.valueOf(this.defaults.getAttackDecayPerSecond())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackDecayButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_decay"), + this.attackDecayField, + this.resetAttackDecayButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_decay") + ) + ); + this.attackEscapeDamageDifficultyPercentField = this.newNumberField(fieldWidth, this.attackEscapeDamageDifficultyPercent); + this.resetAttackEscapeDamageDifficultyPercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackEscapeDamageDifficultyPercentField.setText(String.valueOf(this.defaults.getAttackEscapeDamageDifficultyPercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackEscapeDamageDifficultyPercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_escape_damage_difficulty_percent"), + this.attackEscapeDamageDifficultyPercentField, + this.resetAttackEscapeDamageDifficultyPercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_escape_damage_difficulty_percent") + ) + ); + settingsList.addEntryRow( + SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.player_attack_balance")) + ); + this.pvpFullHealthEscapeBonusPercentField = this.newNumberField(fieldWidth, this.pvpFullHealthEscapeBonusPercent); + this.resetPvpFullHealthEscapeBonusPercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pvpFullHealthEscapeBonusPercentField.setText(String.valueOf(this.defaults.getPvpFullHealthEscapeBonusPercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPvpFullHealthEscapeBonusPercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pvp_full_health_escape_bonus_percent"), + this.pvpFullHealthEscapeBonusPercentField, + this.resetPvpFullHealthEscapeBonusPercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pvp_full_health_escape_bonus_percent") + ) + ); + this.pvpCriticalHealthMashStrengthPercentField = this.newNumberField(fieldWidth, this.pvpCriticalHealthMashStrengthPercent); + this.resetPvpCriticalHealthMashStrengthPercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pvpCriticalHealthMashStrengthPercentField.setText(String.valueOf(this.defaults.getPvpCriticalHealthMashStrengthPercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPvpCriticalHealthMashStrengthPercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pvp_critical_health_mash_strength_percent"), + this.pvpCriticalHealthMashStrengthPercentField, + this.resetPvpCriticalHealthMashStrengthPercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pvp_critical_health_mash_strength_percent") + ) + ); + this.pvpMaxHeatEscapeStrengthPercentField = this.newNumberField(fieldWidth, this.pvpMaxHeatEscapeStrengthPercent); + this.resetPvpMaxHeatEscapeStrengthPercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pvpMaxHeatEscapeStrengthPercentField.setText(String.valueOf(this.defaults.getPvpMaxHeatEscapeStrengthPercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPvpMaxHeatEscapeStrengthPercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pvp_max_heat_escape_strength_percent"), + this.pvpMaxHeatEscapeStrengthPercentField, + this.resetPvpMaxHeatEscapeStrengthPercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pvp_max_heat_escape_strength_percent") + ) + ); + this.pvpZeroHeatAttackStrengthPercentField = this.newNumberField(fieldWidth, this.pvpZeroHeatAttackStrengthPercent); + this.resetPvpZeroHeatAttackStrengthPercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pvpZeroHeatAttackStrengthPercentField.setText(String.valueOf(this.defaults.getPvpZeroHeatAttackStrengthPercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPvpZeroHeatAttackStrengthPercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pvp_zero_heat_attack_strength_percent"), + this.pvpZeroHeatAttackStrengthPercentField, + this.resetPvpZeroHeatAttackStrengthPercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pvp_zero_heat_attack_strength_percent") + ) + ); + this.attackEscapeInvulnerabilitySecondsField = this.newNumberField(fieldWidth, this.attackEscapeInvulnerabilitySeconds); + this.resetAttackEscapeInvulnerabilitySecondsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackEscapeInvulnerabilitySecondsField.setText(String.valueOf(this.defaults.getAttackEscapeInvulnerabilitySeconds())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackEscapeInvulnerabilitySecondsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_escape_invulnerability_seconds"), + this.attackEscapeInvulnerabilitySecondsField, + this.resetAttackEscapeInvulnerabilitySecondsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_escape_invulnerability_seconds") + ) + ); + this.attackEscapeAnimationProtectionSecondsField = this.newNumberField(fieldWidth, this.attackEscapeAnimationProtectionSeconds); + this.resetAttackEscapeAnimationProtectionSecondsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackEscapeAnimationProtectionSecondsField.setText(String.valueOf(this.defaults.getAttackEscapeAnimationProtectionSeconds())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackEscapeAnimationProtectionSecondsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_escape_animation_protection_seconds"), + this.attackEscapeAnimationProtectionSecondsField, + this.resetAttackEscapeAnimationProtectionSecondsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_escape_animation_protection_seconds") + ) + ); + this.postEscapeGatherMaxMobsField = this.newNumberField(fieldWidth, this.postEscapeGatherMaxMobs); + this.resetPostEscapeGatherMaxMobsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.postEscapeGatherMaxMobsField.setText(String.valueOf(this.defaults.getPostEscapeGatherMaxMobs())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPostEscapeGatherMaxMobsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.post_escape_gather_max_mobs"), + this.postEscapeGatherMaxMobsField, + this.resetPostEscapeGatherMaxMobsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.post_escape_gather_max_mobs") + ) + ); + this.attackOutcomeFailsafeThresholdField = this.newNumberField(fieldWidth, this.attackOutcomeFailsafeThreshold); + this.resetAttackOutcomeFailsafeThresholdButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.attackOutcomeFailsafeThresholdField.setText(String.valueOf(this.defaults.getAttackOutcomeFailsafeThreshold())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetAttackOutcomeFailsafeThresholdButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.attack_outcome_failsafe_threshold"), + this.attackOutcomeFailsafeThresholdField, + this.resetAttackOutcomeFailsafeThresholdButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.attack_outcome_failsafe_threshold") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.mount_protection"))); + this.blockAnimationsWhileRidingLivingEntitiesButton = ButtonWidget.builder(this.onOffLabel(this.blockAnimationsWhileRidingLivingEntities), button -> { + this.blockAnimationsWhileRidingLivingEntities = !this.blockAnimationsWhileRidingLivingEntities; + button.setMessage(this.onOffLabel(this.blockAnimationsWhileRidingLivingEntities)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip( + this.blockAnimationsWhileRidingLivingEntitiesButton, "config.needsofnature.tooltip.block_animations_while_riding_living_entities" + ); + this.resetBlockAnimationsWhileRidingLivingEntitiesButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.blockAnimationsWhileRidingLivingEntities = this.defaults.blockAnimationsWhileRidingLivingEntities(); + this.blockAnimationsWhileRidingLivingEntitiesButton.setMessage(this.onOffLabel(this.blockAnimationsWhileRidingLivingEntities)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetBlockAnimationsWhileRidingLivingEntitiesButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.block_animations_while_riding_living_entities"), + this.blockAnimationsWhileRidingLivingEntitiesButton, + this.resetBlockAnimationsWhileRidingLivingEntitiesButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.block_animations_while_riding_living_entities") + ) + ); + this.blockAnimationsWhileRidingVehiclesButton = ButtonWidget.builder(this.onOffLabel(this.blockAnimationsWhileRidingVehicles), button -> { + this.blockAnimationsWhileRidingVehicles = !this.blockAnimationsWhileRidingVehicles; + button.setMessage(this.onOffLabel(this.blockAnimationsWhileRidingVehicles)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.blockAnimationsWhileRidingVehiclesButton, "config.needsofnature.tooltip.block_animations_while_riding_vehicles"); + this.resetBlockAnimationsWhileRidingVehiclesButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.blockAnimationsWhileRidingVehicles = this.defaults.blockAnimationsWhileRidingVehicles(); + this.blockAnimationsWhileRidingVehiclesButton.setMessage(this.onOffLabel(this.blockAnimationsWhileRidingVehicles)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetBlockAnimationsWhileRidingVehiclesButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.block_animations_while_riding_vehicles"), + this.blockAnimationsWhileRidingVehiclesButton, + this.resetBlockAnimationsWhileRidingVehiclesButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.block_animations_while_riding_vehicles") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.last_defeated"))); + this.lastDefeatedEnabledButton = ButtonWidget.builder(this.onOffLabel(this.lastDefeatedEnabled), button -> { + this.lastDefeatedEnabled = !this.lastDefeatedEnabled; + button.setMessage(this.onOffLabel(this.lastDefeatedEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetLastDefeatedEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.lastDefeatedEnabled = this.defaults.isLastDefeatedEnabled(); + this.lastDefeatedEnabledButton.setMessage(this.onOffLabel(this.lastDefeatedEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetLastDefeatedEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.last_defeated_enabled"), + this.lastDefeatedEnabledButton, + this.resetLastDefeatedEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.last_defeated_enabled") + ) + ); + this.lastDefeatedEnergyThresholdField = this.newNumberField(fieldWidth, this.lastDefeatedEnergyThreshold); + this.resetLastDefeatedEnergyThresholdButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.lastDefeatedEnergyThresholdField.setText(String.valueOf(this.defaults.getLastDefeatedEnergyThreshold())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLastDefeatedEnergyThresholdButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.last_defeated_energy_threshold"), + this.lastDefeatedEnergyThresholdField, + this.resetLastDefeatedEnergyThresholdButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.last_defeated_energy_threshold") + ) + ); + this.lastDefeatedSearchRadiusField = this.newNumberField(fieldWidth, this.lastDefeatedSearchRadius); + this.resetLastDefeatedSearchRadiusButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.lastDefeatedSearchRadiusField.setText(String.valueOf(this.defaults.getLastDefeatedSearchRadius())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLastDefeatedSearchRadiusButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.last_defeated_search_radius"), + this.lastDefeatedSearchRadiusField, + this.resetLastDefeatedSearchRadiusButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.last_defeated_search_radius") + ) + ); + this.lastDefeatedCooldownSecondsField = this.newNumberField(fieldWidth, this.lastDefeatedCooldownSeconds); + this.resetLastDefeatedCooldownSecondsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.lastDefeatedCooldownSecondsField.setText(String.valueOf(this.defaults.getLastDefeatedCooldownSeconds())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLastDefeatedCooldownSecondsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.last_defeated_cooldown_seconds"), + this.lastDefeatedCooldownSecondsField, + this.resetLastDefeatedCooldownSecondsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.last_defeated_cooldown_seconds") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.ripped_skin_repair"))); + this.allowCraftingTableSkinRepairButton = ButtonWidget.builder(this.onOffLabel(this.allowCraftingTableSkinRepair), button -> { + this.allowCraftingTableSkinRepair = !this.allowCraftingTableSkinRepair; + button.setMessage(this.onOffLabel(this.allowCraftingTableSkinRepair)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.allowCraftingTableSkinRepairButton, "config.needsofnature.tooltip.allow_crafting_table_skin_repair"); + this.resetAllowCraftingTableSkinRepairButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.allowCraftingTableSkinRepair = this.defaults.isCraftingTableSkinRepairAllowed(); + this.allowCraftingTableSkinRepairButton.setMessage(this.onOffLabel(this.allowCraftingTableSkinRepair)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetAllowCraftingTableSkinRepairButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.allow_crafting_table_skin_repair"), + this.allowCraftingTableSkinRepairButton, + this.resetAllowCraftingTableSkinRepairButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.allow_crafting_table_skin_repair") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.death_persistence"))); + this.keepMessAfterDeathButton = ButtonWidget.builder(this.onOffLabel(this.keepMessAfterDeath), button -> { + this.keepMessAfterDeath = !this.keepMessAfterDeath; + button.setMessage(this.onOffLabel(this.keepMessAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.keepMessAfterDeathButton, "config.needsofnature.tooltip.keep_mess_after_death"); + this.resetKeepMessAfterDeathButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.keepMessAfterDeath = this.defaults.keepMessAfterDeath(); + this.keepMessAfterDeathButton.setMessage(this.onOffLabel(this.keepMessAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetKeepMessAfterDeathButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.keep_mess_after_death"), + this.keepMessAfterDeathButton, + this.resetKeepMessAfterDeathButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.keep_mess_after_death") + ) + ); + this.keepRippedSkinAfterDeathButton = ButtonWidget.builder(this.onOffLabel(this.keepRippedSkinAfterDeath), button -> { + this.keepRippedSkinAfterDeath = !this.keepRippedSkinAfterDeath; + button.setMessage(this.onOffLabel(this.keepRippedSkinAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.keepRippedSkinAfterDeathButton, "config.needsofnature.tooltip.keep_ripped_skin_after_death"); + this.resetKeepRippedSkinAfterDeathButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.keepRippedSkinAfterDeath = this.defaults.keepRippedSkinAfterDeath(); + this.keepRippedSkinAfterDeathButton.setMessage(this.onOffLabel(this.keepRippedSkinAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetKeepRippedSkinAfterDeathButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.keep_ripped_skin_after_death"), + this.keepRippedSkinAfterDeathButton, + this.resetKeepRippedSkinAfterDeathButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.keep_ripped_skin_after_death") + ) + ); + this.keepLiquidTankAfterDeathButton = ButtonWidget.builder(this.onOffLabel(this.keepLiquidTankAfterDeath), button -> { + this.keepLiquidTankAfterDeath = !this.keepLiquidTankAfterDeath; + button.setMessage(this.onOffLabel(this.keepLiquidTankAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.keepLiquidTankAfterDeathButton, "config.needsofnature.tooltip.keep_liquid_tank_after_death"); + this.resetKeepLiquidTankAfterDeathButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.keepLiquidTankAfterDeath = this.defaults.keepLiquidTankAfterDeath(); + this.keepLiquidTankAfterDeathButton.setMessage(this.onOffLabel(this.keepLiquidTankAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetKeepLiquidTankAfterDeathButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.keep_liquid_tank_after_death"), + this.keepLiquidTankAfterDeathButton, + this.resetKeepLiquidTankAfterDeathButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.keep_liquid_tank_after_death") + ) + ); + this.keepPregnancyAfterDeathButton = ButtonWidget.builder(this.onOffLabel(this.keepPregnancyAfterDeath), button -> { + this.keepPregnancyAfterDeath = !this.keepPregnancyAfterDeath; + button.setMessage(this.onOffLabel(this.keepPregnancyAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.keepPregnancyAfterDeathButton, "config.needsofnature.tooltip.keep_pregnancy_after_death"); + this.resetKeepPregnancyAfterDeathButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.keepPregnancyAfterDeath = this.defaults.keepPregnancyAfterDeath(); + this.keepPregnancyAfterDeathButton.setMessage(this.onOffLabel(this.keepPregnancyAfterDeath)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetKeepPregnancyAfterDeathButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.keep_pregnancy_after_death"), + this.keepPregnancyAfterDeathButton, + this.resetKeepPregnancyAfterDeathButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.keep_pregnancy_after_death") + ) + ); + this.playerRequestMinEnergyField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackHitsField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackDecayField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackEscapeDamageDifficultyPercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pvpFullHealthEscapeBonusPercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pvpCriticalHealthMashStrengthPercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pvpMaxHeatEscapeStrengthPercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pvpZeroHeatAttackStrengthPercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackEscapeInvulnerabilitySecondsField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackEscapeAnimationProtectionSecondsField.setChangedListener(ignored -> this.updateResetButtons()); + this.postEscapeGatherMaxMobsField.setChangedListener(ignored -> this.updateResetButtons()); + this.attackOutcomeFailsafeThresholdField.setChangedListener(ignored -> this.updateResetButtons()); + this.lastDefeatedEnergyThresholdField.setChangedListener(ignored -> this.updateResetButtons()); + this.lastDefeatedSearchRadiusField.setChangedListener(ignored -> this.updateResetButtons()); + this.lastDefeatedCooldownSecondsField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerRequestMinEnergyField.setEditable(this.serverConfigEditable); + this.attackHitsField.setEditable(this.serverConfigEditable); + this.attackDecayField.setEditable(this.serverConfigEditable); + this.attackEscapeDamageDifficultyPercentField.setEditable(this.serverConfigEditable); + this.pvpFullHealthEscapeBonusPercentField.setEditable(this.serverConfigEditable); + this.pvpCriticalHealthMashStrengthPercentField.setEditable(this.serverConfigEditable); + this.pvpMaxHeatEscapeStrengthPercentField.setEditable(this.serverConfigEditable); + this.pvpZeroHeatAttackStrengthPercentField.setEditable(this.serverConfigEditable); + this.attackEscapeInvulnerabilitySecondsField.setEditable(this.serverConfigEditable); + this.attackEscapeAnimationProtectionSecondsField.setEditable(this.serverConfigEditable); + this.postEscapeGatherMaxMobsField.setEditable(this.serverConfigEditable); + this.attackOutcomeFailsafeThresholdField.setEditable(this.serverConfigEditable); + this.lastDefeatedEnergyThresholdField.setEditable(this.serverConfigEditable); + this.lastDefeatedSearchRadiusField.setEditable(this.serverConfigEditable); + this.lastDefeatedCooldownSecondsField.setEditable(this.serverConfigEditable); + if (this.lastDefeatedEnabledButton != null) { + this.lastDefeatedEnabledButton.active = this.serverConfigEditable; + } + + if (this.attackCreativePlayersButton != null) { + this.attackCreativePlayersButton.active = this.serverConfigEditable; + } + + if (this.blockAnimationsWhileRidingLivingEntitiesButton != null) { + this.blockAnimationsWhileRidingLivingEntitiesButton.active = this.serverConfigEditable; + } + + if (this.blockAnimationsWhileRidingVehiclesButton != null) { + this.blockAnimationsWhileRidingVehiclesButton.active = this.serverConfigEditable; + } + + if (this.allowCraftingTableSkinRepairButton != null) { + this.allowCraftingTableSkinRepairButton.active = this.serverConfigEditable; + } + + if (this.keepMessAfterDeathButton != null) { + this.keepMessAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepRippedSkinAfterDeathButton != null) { + this.keepRippedSkinAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepLiquidTankAfterDeathButton != null) { + this.keepLiquidTankAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepPregnancyAfterDeathButton != null) { + this.keepPregnancyAfterDeathButton.active = this.serverConfigEditable; + } + + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.playerRequestMinEnergy = this.clampPlayerRequestMinEnergy(this.parseField(this.playerRequestMinEnergyField, this.playerRequestMinEnergy)); + this.attackHits = this.clampAttackHits(this.parseField(this.attackHitsField, this.attackHits)); + this.attackDecay = this.clampAttackDecay(this.parseDecimalField(this.attackDecayField, this.attackDecay)); + this.attackEscapeDamageDifficultyPercent = this.clampAttackEscapeDamageDifficultyPercent( + this.parseField(this.attackEscapeDamageDifficultyPercentField, this.attackEscapeDamageDifficultyPercent) + ); + this.pvpFullHealthEscapeBonusPercent = this.clampPvpMashPercent( + this.parseField(this.pvpFullHealthEscapeBonusPercentField, this.pvpFullHealthEscapeBonusPercent) + ); + this.pvpCriticalHealthMashStrengthPercent = this.clampPvpMashPercent( + this.parseField(this.pvpCriticalHealthMashStrengthPercentField, this.pvpCriticalHealthMashStrengthPercent) + ); + this.pvpMaxHeatEscapeStrengthPercent = this.clampPvpMashPercent( + this.parseField(this.pvpMaxHeatEscapeStrengthPercentField, this.pvpMaxHeatEscapeStrengthPercent) + ); + this.pvpZeroHeatAttackStrengthPercent = this.clampPvpMashPercent( + this.parseField(this.pvpZeroHeatAttackStrengthPercentField, this.pvpZeroHeatAttackStrengthPercent) + ); + this.attackEscapeInvulnerabilitySeconds = this.clampAttackEscapeProtectionSeconds( + this.parseField(this.attackEscapeInvulnerabilitySecondsField, this.attackEscapeInvulnerabilitySeconds) + ); + this.attackEscapeAnimationProtectionSeconds = this.clampAttackEscapeProtectionSeconds( + this.parseField(this.attackEscapeAnimationProtectionSecondsField, this.attackEscapeAnimationProtectionSeconds) + ); + this.postEscapeGatherMaxMobs = this.clampPostEscapeGatherMaxMobs(this.parseField(this.postEscapeGatherMaxMobsField, this.postEscapeGatherMaxMobs)); + this.attackOutcomeFailsafeThreshold = this.clampAttackOutcomeFailsafeThreshold( + this.parseField(this.attackOutcomeFailsafeThresholdField, this.attackOutcomeFailsafeThreshold) + ); + this.lastDefeatedEnergyThreshold = this.clampLastDefeatedEnergyThreshold( + this.parseField(this.lastDefeatedEnergyThresholdField, this.lastDefeatedEnergyThreshold) + ); + this.lastDefeatedSearchRadius = this.clampLastDefeatedSearchRadius( + this.parseField(this.lastDefeatedSearchRadiusField, this.lastDefeatedSearchRadius) + ); + this.lastDefeatedCooldownSeconds = this.clampLastDefeatedCooldownSeconds( + this.parseField(this.lastDefeatedCooldownSecondsField, this.lastDefeatedCooldownSeconds) + ); + this.config.setPlayerRequestMinEnergy(this.playerRequestMinEnergy); + this.config.setAttackEscapeHits(this.attackHits); + this.config.setAttackDecayPerSecond(this.attackDecay); + this.config.setAttackEscapeDamageDifficultyPercent(this.attackEscapeDamageDifficultyPercent); + this.config.setPvpFullHealthEscapeBonusPercent(this.pvpFullHealthEscapeBonusPercent); + this.config.setPvpCriticalHealthMashStrengthPercent(this.pvpCriticalHealthMashStrengthPercent); + this.config.setPvpMaxHeatEscapeStrengthPercent(this.pvpMaxHeatEscapeStrengthPercent); + this.config.setPvpZeroHeatAttackStrengthPercent(this.pvpZeroHeatAttackStrengthPercent); + this.config.setAttackEscapeInvulnerabilitySeconds(this.attackEscapeInvulnerabilitySeconds); + this.config.setAttackEscapeAnimationProtectionSeconds(this.attackEscapeAnimationProtectionSeconds); + this.config.setPostEscapeGatherMaxMobs(this.postEscapeGatherMaxMobs); + this.config.setAttackCreativePlayers(this.attackCreativePlayers); + this.config.setBlockAnimationsWhileRidingLivingEntities(this.blockAnimationsWhileRidingLivingEntities); + this.config.setBlockAnimationsWhileRidingVehicles(this.blockAnimationsWhileRidingVehicles); + this.config.setAttackOutcomeFailsafeThreshold(this.attackOutcomeFailsafeThreshold); + this.config.setLastDefeatedEnabled(this.lastDefeatedEnabled); + this.config.setLastDefeatedEnergyThreshold(this.lastDefeatedEnergyThreshold); + this.config.setLastDefeatedSearchRadius(this.lastDefeatedSearchRadius); + this.config.setLastDefeatedCooldownSeconds(this.lastDefeatedCooldownSeconds); + this.config.setCraftingTableSkinRepairAllowed(this.allowCraftingTableSkinRepair); + this.config.setKeepMessAfterDeath(this.keepMessAfterDeath); + this.config.setKeepRippedSkinAfterDeath(this.keepRippedSkinAfterDeath); + this.config.setKeepLiquidTankAfterDeath(this.keepLiquidTankAfterDeath); + this.config.setKeepPregnancyAfterDeath(this.keepPregnancyAfterDeath); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private TextFieldWidget newDecimalField(int w, double initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + int maxLen = 16; + field.setMaxLength(maxLen); + field.setText(this.trimToMax(this.formatDecimal(initial), maxLen)); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private double parseDecimalField(TextFieldWidget field, double fallback) { + try { + return Double.parseDouble(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private String formatDecimal(double value) { + String text = String.format(Locale.ROOT, "%.3f", value); + int dot = text.indexOf(46); + if (dot >= 0) { + int end = text.length(); + + while (end > dot + 1 && text.charAt(end - 1) == '0') { + end--; + } + + if (end == dot + 1) { + end = dot; + } + + text = text.substring(0, end); + } + + return text; + } + + private String trimToMax(String value, int max) { + if (value == null) { + return ""; + } else { + return value.length() > max ? value.substring(0, max) : value; + } + } + + private int clampAttackHits(int v) { + return Math.max(1, Math.min(50, v)); + } + + private int clampPlayerRequestMinEnergy(int v) { + return Math.max(0, Math.min(200, v)); + } + + private double clampAttackDecay(double v) { + return Math.max(0.0, Math.min(5.0, v)); + } + + private int clampAttackEscapeDamageDifficultyPercent(int v) { + return Math.max(0, Math.min(200, v)); + } + + private int clampPvpMashPercent(int v) { + return Math.max(0, Math.min(200, v)); + } + + private int clampAttackOutcomeFailsafeThreshold(int v) { + return Math.max(1, Math.min(50, v)); + } + + private int clampAttackEscapeProtectionSeconds(int v) { + return Math.max(0, Math.min(10, v)); + } + + private int clampPostEscapeGatherMaxMobs(int v) { + return Math.max(0, Math.min(64, v)); + } + + private int clampLastDefeatedEnergyThreshold(int v) { + return Math.max(0, Math.min(200, v)); + } + + private int clampLastDefeatedSearchRadius(int v) { + return Math.max(1, Math.min(32, v)); + } + + private int clampLastDefeatedCooldownSeconds(int v) { + return Math.max(0, Math.min(3600, v)); + } + + private Text onOffLabel(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseField(field, defaultValue) != defaultValue; + } + + private boolean isDoubleFieldChanged(TextFieldWidget field, double defaultValue) { + if (field == null) { + return false; + } + + double value = this.parseDecimalField(field, defaultValue); + return Math.abs(value - defaultValue) >= 1.0E-4; + } + + private void updateResetButtons() { + if (this.resetPlayerRequestMinEnergyButton != null) { + this.resetPlayerRequestMinEnergyButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.playerRequestMinEnergyField, this.defaults.getPlayerRequestMinEnergy()); + } + + if (this.resetAttackHitsButton != null) { + this.resetAttackHitsButton.active = this.serverConfigEditable && this.isIntFieldChanged(this.attackHitsField, this.defaults.getAttackEscapeHits()); + } + + if (this.resetAttackDecayButton != null) { + this.resetAttackDecayButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.attackDecayField, this.defaults.getAttackDecayPerSecond()); + } + + if (this.resetAttackEscapeDamageDifficultyPercentButton != null) { + this.resetAttackEscapeDamageDifficultyPercentButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.attackEscapeDamageDifficultyPercentField, this.defaults.getAttackEscapeDamageDifficultyPercent()); + } + + if (this.resetPvpFullHealthEscapeBonusPercentButton != null) { + this.resetPvpFullHealthEscapeBonusPercentButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pvpFullHealthEscapeBonusPercentField, this.defaults.getPvpFullHealthEscapeBonusPercent()); + } + + if (this.resetPvpCriticalHealthMashStrengthPercentButton != null) { + this.resetPvpCriticalHealthMashStrengthPercentButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pvpCriticalHealthMashStrengthPercentField, this.defaults.getPvpCriticalHealthMashStrengthPercent()); + } + + if (this.resetPvpMaxHeatEscapeStrengthPercentButton != null) { + this.resetPvpMaxHeatEscapeStrengthPercentButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pvpMaxHeatEscapeStrengthPercentField, this.defaults.getPvpMaxHeatEscapeStrengthPercent()); + } + + if (this.resetPvpZeroHeatAttackStrengthPercentButton != null) { + this.resetPvpZeroHeatAttackStrengthPercentButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pvpZeroHeatAttackStrengthPercentField, this.defaults.getPvpZeroHeatAttackStrengthPercent()); + } + + if (this.resetAttackEscapeInvulnerabilitySecondsButton != null) { + this.resetAttackEscapeInvulnerabilitySecondsButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.attackEscapeInvulnerabilitySecondsField, this.defaults.getAttackEscapeInvulnerabilitySeconds()); + } + + if (this.resetAttackEscapeAnimationProtectionSecondsButton != null) { + this.resetAttackEscapeAnimationProtectionSecondsButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.attackEscapeAnimationProtectionSecondsField, this.defaults.getAttackEscapeAnimationProtectionSeconds()); + } + + if (this.resetPostEscapeGatherMaxMobsButton != null) { + this.resetPostEscapeGatherMaxMobsButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.postEscapeGatherMaxMobsField, this.defaults.getPostEscapeGatherMaxMobs()); + } + + if (this.resetAttackOutcomeFailsafeThresholdButton != null) { + this.resetAttackOutcomeFailsafeThresholdButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.attackOutcomeFailsafeThresholdField, this.defaults.getAttackOutcomeFailsafeThreshold()); + } + + if (this.resetAttackCreativePlayersButton != null) { + this.resetAttackCreativePlayersButton.active = this.serverConfigEditable && this.attackCreativePlayers != this.defaults.canAttackCreativePlayers(); + } + + if (this.resetBlockAnimationsWhileRidingLivingEntitiesButton != null) { + this.resetBlockAnimationsWhileRidingLivingEntitiesButton.active = this.serverConfigEditable + && this.blockAnimationsWhileRidingLivingEntities != this.defaults.blockAnimationsWhileRidingLivingEntities(); + } + + if (this.resetBlockAnimationsWhileRidingVehiclesButton != null) { + this.resetBlockAnimationsWhileRidingVehiclesButton.active = this.serverConfigEditable + && this.blockAnimationsWhileRidingVehicles != this.defaults.blockAnimationsWhileRidingVehicles(); + } + + if (this.resetLastDefeatedEnabledButton != null) { + this.resetLastDefeatedEnabledButton.active = this.serverConfigEditable && this.lastDefeatedEnabled != this.defaults.isLastDefeatedEnabled(); + } + + if (this.resetLastDefeatedEnergyThresholdButton != null) { + this.resetLastDefeatedEnergyThresholdButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.lastDefeatedEnergyThresholdField, this.defaults.getLastDefeatedEnergyThreshold()); + } + + if (this.resetLastDefeatedSearchRadiusButton != null) { + this.resetLastDefeatedSearchRadiusButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.lastDefeatedSearchRadiusField, this.defaults.getLastDefeatedSearchRadius()); + } + + if (this.resetLastDefeatedCooldownSecondsButton != null) { + this.resetLastDefeatedCooldownSecondsButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.lastDefeatedCooldownSecondsField, this.defaults.getLastDefeatedCooldownSeconds()); + } + + if (this.resetAllowCraftingTableSkinRepairButton != null) { + this.resetAllowCraftingTableSkinRepairButton.active = this.serverConfigEditable + && this.allowCraftingTableSkinRepair != this.defaults.isCraftingTableSkinRepairAllowed(); + } + + if (this.resetKeepMessAfterDeathButton != null) { + this.resetKeepMessAfterDeathButton.active = this.serverConfigEditable && this.keepMessAfterDeath != this.defaults.keepMessAfterDeath(); + } + + if (this.resetKeepRippedSkinAfterDeathButton != null) { + this.resetKeepRippedSkinAfterDeathButton.active = this.serverConfigEditable + && this.keepRippedSkinAfterDeath != this.defaults.keepRippedSkinAfterDeath(); + } + + if (this.resetKeepLiquidTankAfterDeathButton != null) { + this.resetKeepLiquidTankAfterDeathButton.active = this.serverConfigEditable + && this.keepLiquidTankAfterDeath != this.defaults.keepLiquidTankAfterDeath(); + } + + if (this.resetKeepPregnancyAfterDeathButton != null) { + this.resetKeepPregnancyAfterDeathButton.active = this.serverConfigEditable + && this.keepPregnancyAfterDeath != this.defaults.keepPregnancyAfterDeath(); + } + + if (this.lastDefeatedEnabledButton != null) { + this.lastDefeatedEnabledButton.active = this.serverConfigEditable; + } + + if (this.blockAnimationsWhileRidingLivingEntitiesButton != null) { + this.blockAnimationsWhileRidingLivingEntitiesButton.active = this.serverConfigEditable; + } + + if (this.blockAnimationsWhileRidingVehiclesButton != null) { + this.blockAnimationsWhileRidingVehiclesButton.active = this.serverConfigEditable; + } + + if (this.allowCraftingTableSkinRepairButton != null) { + this.allowCraftingTableSkinRepairButton.active = this.serverConfigEditable; + } + + if (this.keepMessAfterDeathButton != null) { + this.keepMessAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepRippedSkinAfterDeathButton != null) { + this.keepRippedSkinAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepLiquidTankAfterDeathButton != null) { + this.keepLiquidTankAfterDeathButton.active = this.serverConfigEditable; + } + + if (this.keepPregnancyAfterDeathButton != null) { + this.keepPregnancyAfterDeathButton.active = this.serverConfigEditable; + } + } + } + + static class GameplayEnergyScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private double energyGainRate; + private double nearAnimationEnergyGainMult; + private double playerEnergyAuraMultLow; + private double playerEnergyAuraMultMid; + private double playerEnergyAuraMultHigh; + private double destroyedSkinAuraMultStage1; + private double destroyedSkinAuraMultStage2; + private double destroyedSkinAuraMultStage3; + private double destroyedSkinAuraMultStage4; + private int normalDamageDestroyedSkinChancePercent; + private int playerEnergyAuraRadiusMin; + private int playerEnergyAuraRadiusMax; + private int playerEnergyAuraPulseTicks; + private TextFieldWidget energyGainRateField; + private TextFieldWidget nearAnimationEnergyGainMultField; + private TextFieldWidget playerEnergyAuraMultLowField; + private TextFieldWidget playerEnergyAuraMultMidField; + private TextFieldWidget playerEnergyAuraMultHighField; + private TextFieldWidget destroyedSkinAuraMultStage1Field; + private TextFieldWidget destroyedSkinAuraMultStage2Field; + private TextFieldWidget destroyedSkinAuraMultStage3Field; + private TextFieldWidget destroyedSkinAuraMultStage4Field; + private TextFieldWidget normalDamageDestroyedSkinChanceField; + private TextFieldWidget playerEnergyAuraRadiusMinField; + private TextFieldWidget playerEnergyAuraRadiusMaxField; + private TextFieldWidget playerEnergyAuraPulseTicksField; + private ButtonWidget resetEnergyGainRateButton; + private ButtonWidget resetNearAnimationEnergyGainMultButton; + private ButtonWidget resetPlayerEnergyAuraMultLowButton; + private ButtonWidget resetPlayerEnergyAuraMultMidButton; + private ButtonWidget resetPlayerEnergyAuraMultHighButton; + private ButtonWidget resetDestroyedSkinAuraMultStage1Button; + private ButtonWidget resetDestroyedSkinAuraMultStage2Button; + private ButtonWidget resetDestroyedSkinAuraMultStage3Button; + private ButtonWidget resetDestroyedSkinAuraMultStage4Button; + private ButtonWidget resetNormalDamageDestroyedSkinChanceButton; + private ButtonWidget resetPlayerEnergyAuraRadiusMinButton; + private ButtonWidget resetPlayerEnergyAuraRadiusMaxButton; + private ButtonWidget resetPlayerEnergyAuraPulseTicksButton; + private boolean serverConfigEditable; + + protected GameplayEnergyScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.section.energy")); + this.parent = parent; + this.config = config; + this.energyGainRate = config.getEnergyGainRate(); + this.nearAnimationEnergyGainMult = config.getNearAnimationEnergyGainMult(); + this.playerEnergyAuraMultLow = config.getPlayerEnergyAuraMultLow(); + this.playerEnergyAuraMultMid = config.getPlayerEnergyAuraMultMid(); + this.playerEnergyAuraMultHigh = config.getPlayerEnergyAuraMultHigh(); + this.destroyedSkinAuraMultStage1 = config.getDestroyedSkinAuraMultStage1(); + this.destroyedSkinAuraMultStage2 = config.getDestroyedSkinAuraMultStage2(); + this.destroyedSkinAuraMultStage3 = config.getDestroyedSkinAuraMultStage3(); + this.destroyedSkinAuraMultStage4 = config.getDestroyedSkinAuraMultStage4(); + this.normalDamageDestroyedSkinChancePercent = config.getNormalDamageDestroyedSkinChancePercent(); + this.playerEnergyAuraRadiusMin = config.getPlayerEnergyAuraRadiusMin(); + this.playerEnergyAuraRadiusMax = config.getPlayerEnergyAuraRadiusMax(); + this.playerEnergyAuraPulseTicks = config.getPlayerEnergyAuraPulseTicks(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.energyGainRateField != null) { + this.energyGainRate = this.parseDecimalField(this.energyGainRateField, this.energyGainRate); + this.nearAnimationEnergyGainMult = this.parseDecimalField(this.nearAnimationEnergyGainMultField, this.nearAnimationEnergyGainMult); + this.playerEnergyAuraMultLow = this.parseDecimalField(this.playerEnergyAuraMultLowField, this.playerEnergyAuraMultLow); + this.playerEnergyAuraMultMid = this.parseDecimalField(this.playerEnergyAuraMultMidField, this.playerEnergyAuraMultMid); + this.playerEnergyAuraMultHigh = this.parseDecimalField(this.playerEnergyAuraMultHighField, this.playerEnergyAuraMultHigh); + this.destroyedSkinAuraMultStage1 = this.parseDecimalField(this.destroyedSkinAuraMultStage1Field, this.destroyedSkinAuraMultStage1); + this.destroyedSkinAuraMultStage2 = this.parseDecimalField(this.destroyedSkinAuraMultStage2Field, this.destroyedSkinAuraMultStage2); + this.destroyedSkinAuraMultStage3 = this.parseDecimalField(this.destroyedSkinAuraMultStage3Field, this.destroyedSkinAuraMultStage3); + this.destroyedSkinAuraMultStage4 = this.parseDecimalField(this.destroyedSkinAuraMultStage4Field, this.destroyedSkinAuraMultStage4); + this.normalDamageDestroyedSkinChancePercent = this.parseNumberField( + this.normalDamageDestroyedSkinChanceField, this.normalDamageDestroyedSkinChancePercent + ); + this.playerEnergyAuraRadiusMin = this.parseNumberField(this.playerEnergyAuraRadiusMinField, this.playerEnergyAuraRadiusMin); + this.playerEnergyAuraRadiusMax = this.parseNumberField(this.playerEnergyAuraRadiusMaxField, this.playerEnergyAuraRadiusMax); + this.playerEnergyAuraPulseTicks = this.parseNumberField(this.playerEnergyAuraPulseTicksField, this.playerEnergyAuraPulseTicks); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.energy_gain"))); + this.energyGainRateField = this.newDecimalField(fieldWidth, this.energyGainRate); + this.resetEnergyGainRateButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.energyGainRateField.setText(String.valueOf(this.defaults.getEnergyGainRate())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetEnergyGainRateButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.energy_gain_rate"), + this.energyGainRateField, + this.resetEnergyGainRateButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.energy_gain_rate") + ) + ); + this.nearAnimationEnergyGainMultField = this.newDecimalField(fieldWidth, this.nearAnimationEnergyGainMult); + this.resetNearAnimationEnergyGainMultButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.nearAnimationEnergyGainMultField.setText(String.valueOf(this.defaults.getNearAnimationEnergyGainMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetNearAnimationEnergyGainMultButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.near_animation_energy_gain_mult"), + this.nearAnimationEnergyGainMultField, + this.resetNearAnimationEnergyGainMultButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.near_animation_energy_gain_mult") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.player_energy_aura"))); + this.playerEnergyAuraMultLowField = this.newDecimalField(fieldWidth, this.playerEnergyAuraMultLow); + this.resetPlayerEnergyAuraMultLowButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraMultLowField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraMultLow())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraMultLowButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_mult_low"), + this.playerEnergyAuraMultLowField, + this.resetPlayerEnergyAuraMultLowButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_mult_low") + ) + ); + this.playerEnergyAuraMultMidField = this.newDecimalField(fieldWidth, this.playerEnergyAuraMultMid); + this.resetPlayerEnergyAuraMultMidButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraMultMidField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraMultMid())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraMultMidButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_mult_mid"), + this.playerEnergyAuraMultMidField, + this.resetPlayerEnergyAuraMultMidButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_mult_mid") + ) + ); + this.playerEnergyAuraMultHighField = this.newDecimalField(fieldWidth, this.playerEnergyAuraMultHigh); + this.resetPlayerEnergyAuraMultHighButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraMultHighField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraMultHigh())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraMultHighButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_mult_high"), + this.playerEnergyAuraMultHighField, + this.resetPlayerEnergyAuraMultHighButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_mult_high") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.destroyed_skin"))); + this.destroyedSkinAuraMultStage1Field = this.newDecimalField(fieldWidth, this.destroyedSkinAuraMultStage1); + this.resetDestroyedSkinAuraMultStage1Button = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.destroyedSkinAuraMultStage1Field.setText(String.valueOf(this.defaults.getDestroyedSkinAuraMultStage1())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetDestroyedSkinAuraMultStage1Button, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_aura_mult_stage1"), + this.destroyedSkinAuraMultStage1Field, + this.resetDestroyedSkinAuraMultStage1Button, + this.destroyedSkinSettingTooltip("config.needsofnature.tooltip.destroyed_skin_aura_mult_stage1") + ) + ); + this.destroyedSkinAuraMultStage2Field = this.newDecimalField(fieldWidth, this.destroyedSkinAuraMultStage2); + this.resetDestroyedSkinAuraMultStage2Button = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.destroyedSkinAuraMultStage2Field.setText(String.valueOf(this.defaults.getDestroyedSkinAuraMultStage2())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetDestroyedSkinAuraMultStage2Button, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_aura_mult_stage2"), + this.destroyedSkinAuraMultStage2Field, + this.resetDestroyedSkinAuraMultStage2Button, + this.destroyedSkinSettingTooltip("config.needsofnature.tooltip.destroyed_skin_aura_mult_stage2") + ) + ); + this.destroyedSkinAuraMultStage3Field = this.newDecimalField(fieldWidth, this.destroyedSkinAuraMultStage3); + this.resetDestroyedSkinAuraMultStage3Button = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.destroyedSkinAuraMultStage3Field.setText(String.valueOf(this.defaults.getDestroyedSkinAuraMultStage3())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetDestroyedSkinAuraMultStage3Button, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_aura_mult_stage3"), + this.destroyedSkinAuraMultStage3Field, + this.resetDestroyedSkinAuraMultStage3Button, + this.destroyedSkinSettingTooltip("config.needsofnature.tooltip.destroyed_skin_aura_mult_stage3") + ) + ); + this.destroyedSkinAuraMultStage4Field = this.newDecimalField(fieldWidth, this.destroyedSkinAuraMultStage4); + this.resetDestroyedSkinAuraMultStage4Button = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.destroyedSkinAuraMultStage4Field.setText(String.valueOf(this.defaults.getDestroyedSkinAuraMultStage4())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetDestroyedSkinAuraMultStage4Button, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_aura_mult_stage4"), + this.destroyedSkinAuraMultStage4Field, + this.resetDestroyedSkinAuraMultStage4Button, + this.destroyedSkinSettingTooltip("config.needsofnature.tooltip.destroyed_skin_aura_mult_stage4") + ) + ); + this.normalDamageDestroyedSkinChanceField = this.newNumberField(fieldWidth, this.normalDamageDestroyedSkinChancePercent); + this.resetNormalDamageDestroyedSkinChanceButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.normalDamageDestroyedSkinChanceField.setText(String.valueOf(this.defaults.getNormalDamageDestroyedSkinChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetNormalDamageDestroyedSkinChanceButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.normal_damage_destroyed_skin_chance_percent"), + this.normalDamageDestroyedSkinChanceField, + this.resetNormalDamageDestroyedSkinChanceButton, + this.destroyedSkinSettingTooltip("config.needsofnature.tooltip.normal_damage_destroyed_skin_chance_percent") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.aura_range_timing"))); + this.playerEnergyAuraRadiusMinField = this.newNumberField(fieldWidth, this.playerEnergyAuraRadiusMin); + this.resetPlayerEnergyAuraRadiusMinButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraRadiusMinField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraRadiusMin())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraRadiusMinButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_radius_min"), + this.playerEnergyAuraRadiusMinField, + this.resetPlayerEnergyAuraRadiusMinButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_radius_min") + ) + ); + this.playerEnergyAuraRadiusMaxField = this.newNumberField(fieldWidth, this.playerEnergyAuraRadiusMax); + this.resetPlayerEnergyAuraRadiusMaxButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraRadiusMaxField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraRadiusMax())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraRadiusMaxButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_radius_max"), + this.playerEnergyAuraRadiusMaxField, + this.resetPlayerEnergyAuraRadiusMaxButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_radius_max") + ) + ); + this.playerEnergyAuraPulseTicksField = this.newNumberField(fieldWidth, this.playerEnergyAuraPulseTicks); + this.resetPlayerEnergyAuraPulseTicksButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.playerEnergyAuraPulseTicksField.setText(String.valueOf(this.defaults.getPlayerEnergyAuraPulseTicks())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPlayerEnergyAuraPulseTicksButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_energy_aura_pulse_ticks"), + this.playerEnergyAuraPulseTicksField, + this.resetPlayerEnergyAuraPulseTicksButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.player_energy_aura_pulse_ticks") + ) + ); + this.energyGainRateField.setChangedListener(ignored -> this.updateResetButtons()); + this.nearAnimationEnergyGainMultField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraMultLowField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraMultMidField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraMultHighField.setChangedListener(ignored -> this.updateResetButtons()); + this.destroyedSkinAuraMultStage1Field.setChangedListener(ignored -> this.updateResetButtons()); + this.destroyedSkinAuraMultStage2Field.setChangedListener(ignored -> this.updateResetButtons()); + this.destroyedSkinAuraMultStage3Field.setChangedListener(ignored -> this.updateResetButtons()); + this.destroyedSkinAuraMultStage4Field.setChangedListener(ignored -> this.updateResetButtons()); + this.normalDamageDestroyedSkinChanceField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraRadiusMinField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraRadiusMaxField.setChangedListener(ignored -> this.updateResetButtons()); + this.playerEnergyAuraPulseTicksField.setChangedListener(ignored -> this.updateResetButtons()); + this.energyGainRateField.setEditable(this.serverConfigEditable); + this.nearAnimationEnergyGainMultField.setEditable(this.serverConfigEditable); + this.playerEnergyAuraMultLowField.setEditable(this.serverConfigEditable); + this.playerEnergyAuraMultMidField.setEditable(this.serverConfigEditable); + this.playerEnergyAuraMultHighField.setEditable(this.serverConfigEditable); + this.destroyedSkinAuraMultStage1Field.setEditable(this.destroyedSkinSettingsEditable()); + this.destroyedSkinAuraMultStage2Field.setEditable(this.destroyedSkinSettingsEditable()); + this.destroyedSkinAuraMultStage3Field.setEditable(this.destroyedSkinSettingsEditable()); + this.destroyedSkinAuraMultStage4Field.setEditable(this.destroyedSkinSettingsEditable()); + this.normalDamageDestroyedSkinChanceField.setEditable(this.destroyedSkinSettingsEditable()); + this.playerEnergyAuraRadiusMinField.setEditable(this.serverConfigEditable); + this.playerEnergyAuraRadiusMaxField.setEditable(this.serverConfigEditable); + this.playerEnergyAuraPulseTicksField.setEditable(this.serverConfigEditable); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.energyGainRate = this.clampEnergyGainRate(this.parseDecimalField(this.energyGainRateField, this.energyGainRate)); + this.nearAnimationEnergyGainMult = this.clampNearAnimationEnergyGainMult( + this.parseDecimalField(this.nearAnimationEnergyGainMultField, this.nearAnimationEnergyGainMult) + ); + this.playerEnergyAuraMultLow = this.clampPlayerEnergyAuraMult( + this.parseDecimalField(this.playerEnergyAuraMultLowField, this.playerEnergyAuraMultLow) + ); + this.playerEnergyAuraMultMid = this.clampPlayerEnergyAuraMult( + this.parseDecimalField(this.playerEnergyAuraMultMidField, this.playerEnergyAuraMultMid) + ); + this.playerEnergyAuraMultHigh = this.clampPlayerEnergyAuraMult( + this.parseDecimalField(this.playerEnergyAuraMultHighField, this.playerEnergyAuraMultHigh) + ); + this.destroyedSkinAuraMultStage1 = this.clampDestroyedSkinAuraMult( + this.parseDecimalField(this.destroyedSkinAuraMultStage1Field, this.destroyedSkinAuraMultStage1) + ); + this.destroyedSkinAuraMultStage2 = this.clampDestroyedSkinAuraMult( + this.parseDecimalField(this.destroyedSkinAuraMultStage2Field, this.destroyedSkinAuraMultStage2) + ); + this.destroyedSkinAuraMultStage3 = this.clampDestroyedSkinAuraMult( + this.parseDecimalField(this.destroyedSkinAuraMultStage3Field, this.destroyedSkinAuraMultStage3) + ); + this.destroyedSkinAuraMultStage4 = this.clampDestroyedSkinAuraMult( + this.parseDecimalField(this.destroyedSkinAuraMultStage4Field, this.destroyedSkinAuraMultStage4) + ); + this.normalDamageDestroyedSkinChancePercent = this.clampPercent( + this.parseNumberField(this.normalDamageDestroyedSkinChanceField, this.normalDamageDestroyedSkinChancePercent) + ); + this.playerEnergyAuraRadiusMin = this.clampPlayerEnergyAuraRadius( + this.parseNumberField(this.playerEnergyAuraRadiusMinField, this.playerEnergyAuraRadiusMin) + ); + this.playerEnergyAuraRadiusMax = Math.max( + this.playerEnergyAuraRadiusMin, + this.clampPlayerEnergyAuraRadius(this.parseNumberField(this.playerEnergyAuraRadiusMaxField, this.playerEnergyAuraRadiusMax)) + ); + this.playerEnergyAuraPulseTicks = this.clampPlayerEnergyAuraPulseTicks( + this.parseNumberField(this.playerEnergyAuraPulseTicksField, this.playerEnergyAuraPulseTicks) + ); + this.config.setEnergyGainRate(this.energyGainRate); + this.config.setNearAnimationEnergyGainMult(this.nearAnimationEnergyGainMult); + this.config.setPlayerEnergyAuraMultLow(this.playerEnergyAuraMultLow); + this.config.setPlayerEnergyAuraMultMid(this.playerEnergyAuraMultMid); + this.config.setPlayerEnergyAuraMultHigh(this.playerEnergyAuraMultHigh); + this.config.setDestroyedSkinAuraMultStage1(this.destroyedSkinAuraMultStage1); + this.config.setDestroyedSkinAuraMultStage2(this.destroyedSkinAuraMultStage2); + this.config.setDestroyedSkinAuraMultStage3(this.destroyedSkinAuraMultStage3); + this.config.setDestroyedSkinAuraMultStage4(this.destroyedSkinAuraMultStage4); + this.config.setNormalDamageDestroyedSkinChancePercent(this.normalDamageDestroyedSkinChancePercent); + this.config.setPlayerEnergyAuraRadiusMin(this.playerEnergyAuraRadiusMin); + this.config.setPlayerEnergyAuraRadiusMax(this.playerEnergyAuraRadiusMax); + this.config.setPlayerEnergyAuraPulseTicks(this.playerEnergyAuraPulseTicks); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newDecimalField(int w, double initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + int maxLen = 16; + field.setMaxLength(maxLen); + field.setText(this.trimToMax(this.formatDecimal(initial), maxLen)); + field.setEditable(true); + return field; + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + int maxLen = 16; + field.setMaxLength(maxLen); + field.setText(this.trimToMax(String.valueOf(initial), maxLen)); + field.setEditable(true); + return field; + } + + private double parseDecimalField(TextFieldWidget field, double fallback) { + try { + return Double.parseDouble(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int parseNumberField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private String formatDecimal(double value) { + String text = String.format(Locale.ROOT, "%.3f", value); + int dot = text.indexOf(46); + if (dot >= 0) { + int end = text.length(); + + while (end > dot + 1 && text.charAt(end - 1) == '0') { + end--; + } + + if (end == dot + 1) { + end = dot; + } + + text = text.substring(0, end); + } + + return text; + } + + private String trimToMax(String value, int max) { + if (value == null) { + return ""; + } else { + return value.length() > max ? value.substring(0, max) : value; + } + } + + private double clampEnergyGainRate(double v) { + return Math.max(0.1, Math.min(10.0, v)); + } + + private double clampNearAnimationEnergyGainMult(double v) { + return Math.max(1.0, Math.min(10.0, v)); + } + + private double clampPlayerEnergyAuraMult(double v) { + return Math.max(1.0, Math.min(20.0, v)); + } + + private double clampDestroyedSkinAuraMult(double v) { + return Math.max(1.0, Math.min(50.0, v)); + } + + private int clampPercent(int v) { + return Math.max(0, Math.min(100, v)); + } + + private int clampPlayerEnergyAuraRadius(int v) { + return Math.max(1, Math.min(64, v)); + } + + private int clampPlayerEnergyAuraPulseTicks(int v) { + return Math.max(1, Math.min(1200, v)); + } + + private boolean destroyedSkinSettingsEditable() { + return this.serverConfigEditable && this.config.isDestroyedSkinSystemEnabled(); + } + + private Tooltip destroyedSkinSettingTooltip(String enabledKey) { + return NonModMenuScreens.tooltip( + this.config.isDestroyedSkinSystemEnabled() ? enabledKey : "config.needsofnature.tooltip.destroyed_skin_system.disabled_setting" + ); + } + + private boolean isDoubleFieldChanged(TextFieldWidget field, double defaultValue) { + if (field == null) { + return false; + } + + double value = this.parseDecimalField(field, defaultValue); + return Math.abs(value - defaultValue) >= 1.0E-4; + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseNumberField(field, defaultValue) != defaultValue; + } + + private void updateResetButtons() { + if (this.resetEnergyGainRateButton != null) { + this.resetEnergyGainRateButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.energyGainRateField, this.defaults.getEnergyGainRate()); + } + + if (this.resetNearAnimationEnergyGainMultButton != null) { + this.resetNearAnimationEnergyGainMultButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.nearAnimationEnergyGainMultField, this.defaults.getNearAnimationEnergyGainMult()); + } + + if (this.resetPlayerEnergyAuraMultLowButton != null) { + this.resetPlayerEnergyAuraMultLowButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.playerEnergyAuraMultLowField, this.defaults.getPlayerEnergyAuraMultLow()); + } + + if (this.resetPlayerEnergyAuraMultMidButton != null) { + this.resetPlayerEnergyAuraMultMidButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.playerEnergyAuraMultMidField, this.defaults.getPlayerEnergyAuraMultMid()); + } + + if (this.resetPlayerEnergyAuraMultHighButton != null) { + this.resetPlayerEnergyAuraMultHighButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.playerEnergyAuraMultHighField, this.defaults.getPlayerEnergyAuraMultHigh()); + } + + if (this.resetDestroyedSkinAuraMultStage1Button != null) { + this.resetDestroyedSkinAuraMultStage1Button.active = this.destroyedSkinSettingsEditable() + && this.isDoubleFieldChanged(this.destroyedSkinAuraMultStage1Field, this.defaults.getDestroyedSkinAuraMultStage1()); + } + + if (this.resetDestroyedSkinAuraMultStage2Button != null) { + this.resetDestroyedSkinAuraMultStage2Button.active = this.destroyedSkinSettingsEditable() + && this.isDoubleFieldChanged(this.destroyedSkinAuraMultStage2Field, this.defaults.getDestroyedSkinAuraMultStage2()); + } + + if (this.resetDestroyedSkinAuraMultStage3Button != null) { + this.resetDestroyedSkinAuraMultStage3Button.active = this.destroyedSkinSettingsEditable() + && this.isDoubleFieldChanged(this.destroyedSkinAuraMultStage3Field, this.defaults.getDestroyedSkinAuraMultStage3()); + } + + if (this.resetDestroyedSkinAuraMultStage4Button != null) { + this.resetDestroyedSkinAuraMultStage4Button.active = this.destroyedSkinSettingsEditable() + && this.isDoubleFieldChanged(this.destroyedSkinAuraMultStage4Field, this.defaults.getDestroyedSkinAuraMultStage4()); + } + + if (this.resetNormalDamageDestroyedSkinChanceButton != null) { + this.resetNormalDamageDestroyedSkinChanceButton.active = this.destroyedSkinSettingsEditable() + && this.isIntFieldChanged(this.normalDamageDestroyedSkinChanceField, this.defaults.getNormalDamageDestroyedSkinChancePercent()); + } + + if (this.resetPlayerEnergyAuraRadiusMinButton != null) { + this.resetPlayerEnergyAuraRadiusMinButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.playerEnergyAuraRadiusMinField, this.defaults.getPlayerEnergyAuraRadiusMin()); + } + + if (this.resetPlayerEnergyAuraRadiusMaxButton != null) { + this.resetPlayerEnergyAuraRadiusMaxButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.playerEnergyAuraRadiusMaxField, this.defaults.getPlayerEnergyAuraRadiusMax()); + } + + if (this.resetPlayerEnergyAuraPulseTicksButton != null) { + this.resetPlayerEnergyAuraPulseTicksButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.playerEnergyAuraPulseTicksField, this.defaults.getPlayerEnergyAuraPulseTicks()); + } + } + } + + static class GameplayWorldScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int initialEnergyMax; + private int genderMalePercent; + private int genderFemalePercent; + private int genderBothPercent; + private boolean allowPlayerGenderChangeAnytime; + private boolean requirePlayerGenderSelectionOnJoin; + private int allowedStartingGenderMask; + private TextFieldWidget initialEnergyField; + private TextFieldWidget genderMaleField; + private TextFieldWidget genderFemaleField; + private TextFieldWidget genderBothField; + private ButtonWidget allowPlayerGenderChangeButton; + private ButtonWidget requirePlayerGenderSelectionButton; + private ButtonWidget allowStartingMaleButton; + private ButtonWidget allowStartingFemaleButton; + private ButtonWidget allowStartingBothButton; + private ButtonWidget resetInitialEnergyButton; + private ButtonWidget resetGenderMaleButton; + private ButtonWidget resetGenderFemaleButton; + private ButtonWidget resetGenderBothButton; + private ButtonWidget resetAllowPlayerGenderChangeButton; + private ButtonWidget resetRequirePlayerGenderSelectionButton; + private ButtonWidget resetAllowedStartingGendersButton; + private boolean serverConfigEditable; + + protected GameplayWorldScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.section.world")); + this.parent = parent; + this.config = config; + this.initialEnergyMax = config.getInitialEnergyMax(); + this.genderMalePercent = config.getGenderMaleChancePercent(); + this.genderFemalePercent = config.getGenderFemaleChancePercent(); + this.genderBothPercent = config.getGenderBothChancePercent(); + this.allowPlayerGenderChangeAnytime = config.allowPlayerGenderChangeAnytime(); + this.requirePlayerGenderSelectionOnJoin = config.requirePlayerGenderSelectionOnJoin(); + this.allowedStartingGenderMask = config.getAllowedStartingGenderMask(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.initialEnergyField != null) { + this.initialEnergyMax = this.parseField(this.initialEnergyField, this.initialEnergyMax); + this.genderMalePercent = this.parseField(this.genderMaleField, this.genderMalePercent); + this.genderFemalePercent = this.parseField(this.genderFemaleField, this.genderFemalePercent); + this.genderBothPercent = this.parseField(this.genderBothField, this.genderBothPercent); + } + + int listTop = 32; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.mob_energy"))); + this.initialEnergyField = this.newNumberField(fieldWidth, this.initialEnergyMax); + this.resetInitialEnergyButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.initialEnergyField.setText(String.valueOf(this.defaults.getInitialEnergyMax())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetInitialEnergyButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.initial_energy_max"), + this.initialEnergyField, + this.resetInitialEnergyButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.initial_energy_max") + ) + ); + settingsList.addEntryRow( + SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.gender_spawn_chances")) + ); + this.genderMaleField = this.newNumberField(fieldWidth, this.genderMalePercent); + this.resetGenderMaleButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.genderMaleField.setText(String.valueOf(this.defaults.getGenderMaleChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetGenderMaleButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.gender_male"), + this.genderMaleField, + this.resetGenderMaleButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.gender_male") + ) + ); + this.genderFemaleField = this.newNumberField(fieldWidth, this.genderFemalePercent); + this.resetGenderFemaleButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.genderFemaleField.setText(String.valueOf(this.defaults.getGenderFemaleChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetGenderFemaleButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.gender_female"), + this.genderFemaleField, + this.resetGenderFemaleButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.gender_female") + ) + ); + this.genderBothField = this.newNumberField(fieldWidth, this.genderBothPercent); + this.resetGenderBothButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.genderBothField.setText(String.valueOf(this.defaults.getGenderBothChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetGenderBothButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.gender_both"), + this.genderBothField, + this.resetGenderBothButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.gender_both") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.player_gender_rules"))); + this.allowPlayerGenderChangeButton = ButtonWidget.builder(this.onOffLabel(this.allowPlayerGenderChangeAnytime), button -> { + this.allowPlayerGenderChangeAnytime = !this.allowPlayerGenderChangeAnytime; + button.setMessage(this.onOffLabel(this.allowPlayerGenderChangeAnytime)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetAllowPlayerGenderChangeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.allowPlayerGenderChangeAnytime = this.defaults.allowPlayerGenderChangeAnytime(); + this.allowPlayerGenderChangeButton.setMessage(this.onOffLabel(this.allowPlayerGenderChangeAnytime)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetAllowPlayerGenderChangeButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.allow_player_gender_change_anytime"), + this.allowPlayerGenderChangeButton, + this.resetAllowPlayerGenderChangeButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.allow_player_gender_change_anytime") + ) + ); + this.requirePlayerGenderSelectionButton = ButtonWidget.builder(this.onOffLabel(this.requirePlayerGenderSelectionOnJoin), button -> { + this.requirePlayerGenderSelectionOnJoin = !this.requirePlayerGenderSelectionOnJoin; + button.setMessage(this.onOffLabel(this.requirePlayerGenderSelectionOnJoin)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetRequirePlayerGenderSelectionButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.requirePlayerGenderSelectionOnJoin = this.defaults.requirePlayerGenderSelectionOnJoin(); + this.requirePlayerGenderSelectionButton.setMessage(this.onOffLabel(this.requirePlayerGenderSelectionOnJoin)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetRequirePlayerGenderSelectionButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.require_player_gender_selection_on_join"), + this.requirePlayerGenderSelectionButton, + this.resetRequirePlayerGenderSelectionButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.require_player_gender_selection_on_join") + ) + ); + this.allowStartingMaleButton = ButtonWidget.builder( + this.startingGenderToggleLabel("config.needsofnature.player_gender.male", this.isStartingGenderChoiceAllowed(1)), button -> { + this.allowedStartingGenderMask = this.toggleAllowedStartingGenderChoice(this.allowedStartingGenderMask, 1); + this.updateAllowedStartingGenderButtons(); + this.updateResetButtons(); + } + ) + .dimensions(0, 0, 64, 20) + .build(); + this.allowStartingFemaleButton = ButtonWidget.builder( + this.startingGenderToggleLabel("config.needsofnature.player_gender.female", this.isStartingGenderChoiceAllowed(2)), button -> { + this.allowedStartingGenderMask = this.toggleAllowedStartingGenderChoice(this.allowedStartingGenderMask, 2); + this.updateAllowedStartingGenderButtons(); + this.updateResetButtons(); + } + ) + .dimensions(0, 0, 70, 20) + .build(); + this.allowStartingBothButton = ButtonWidget.builder( + this.startingGenderToggleLabel("config.needsofnature.player_gender.both", this.isStartingGenderChoiceAllowed(4)), button -> { + this.allowedStartingGenderMask = this.toggleAllowedStartingGenderChoice(this.allowedStartingGenderMask, 4); + this.updateAllowedStartingGenderButtons(); + this.updateResetButtons(); + } + ) + .dimensions(0, 0, 98, 20) + .build(); + this.resetAllowedStartingGendersButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.allowedStartingGenderMask = this.defaults.getAllowedStartingGenderMask(); + this.updateAllowedStartingGenderButtons(); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.allowStartingMaleButton, "config.needsofnature.tooltip.allowed_starting_genders"); + NonModMenuScreens.setTooltip(this.allowStartingFemaleButton, "config.needsofnature.tooltip.allowed_starting_genders"); + NonModMenuScreens.setTooltip(this.allowStartingBothButton, "config.needsofnature.tooltip.allowed_starting_genders"); + NonModMenuScreens.setTooltip(this.resetAllowedStartingGendersButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.allowed_starting_genders"), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.allowed_starting_genders"), + this.allowStartingMaleButton, + this.allowStartingFemaleButton, + this.allowStartingBothButton, + this.resetAllowedStartingGendersButton + ) + ); + this.initialEnergyField.setChangedListener(ignored -> this.updateResetButtons()); + this.genderMaleField.setChangedListener(ignored -> this.updateResetButtons()); + this.genderFemaleField.setChangedListener(ignored -> this.updateResetButtons()); + this.genderBothField.setChangedListener(ignored -> this.updateResetButtons()); + this.initialEnergyField.setEditable(this.serverConfigEditable); + this.genderMaleField.setEditable(this.serverConfigEditable); + this.genderFemaleField.setEditable(this.serverConfigEditable); + this.genderBothField.setEditable(this.serverConfigEditable); + this.allowPlayerGenderChangeButton.active = this.serverConfigEditable; + this.requirePlayerGenderSelectionButton.active = this.serverConfigEditable; + this.allowStartingMaleButton.active = this.serverConfigEditable; + this.allowStartingFemaleButton.active = this.serverConfigEditable; + this.allowStartingBothButton.active = this.serverConfigEditable; + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget genderSpawnButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.gender_spawn_button"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.GenderSpawnConfigScreen(this, this.config)) + ) + .dimensions(centerX - 100, this.height - 52, 200, 20) + .build(); + NonModMenuScreens.setTooltip(genderSpawnButton, "config.needsofnature.tooltip.gender_spawn_button"); + this.addDrawableChild(genderSpawnButton); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.initialEnergyMax = this.clampInitialEnergy(this.parseField(this.initialEnergyField, this.initialEnergyMax)); + this.genderMalePercent = this.clampPercent(this.parseField(this.genderMaleField, this.genderMalePercent)); + this.genderFemalePercent = this.clampPercent(this.parseField(this.genderFemaleField, this.genderFemalePercent)); + this.genderBothPercent = this.clampPercent(this.parseField(this.genderBothField, this.genderBothPercent)); + this.config.setInitialEnergyMax(this.initialEnergyMax); + this.config.setGlobalGenderSpawnChances(new NonConfig.GenderSpawnChances(this.genderMalePercent, this.genderFemalePercent, this.genderBothPercent)); + this.config.setAllowPlayerGenderChangeAnytime(this.allowPlayerGenderChangeAnytime); + this.config.setRequirePlayerGenderSelectionOnJoin(this.requirePlayerGenderSelectionOnJoin); + this.config.setAllowedStartingGenderMask(this.allowedStartingGenderMask); + } + + if (this.serverConfigEditable) { + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int clampInitialEnergy(int v) { + return Math.max(0, Math.min(200, v)); + } + + private int clampPercent(int v) { + return Math.max(0, Math.min(100, v)); + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseField(field, defaultValue) != defaultValue; + } + + private Text onOffLabel(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private Text startingGenderToggleLabel(String labelKey, boolean enabled) { + return Text.translatable( + enabled ? "config.needsofnature.toggle_label.enabled" : "config.needsofnature.toggle_label.disabled", new Object[]{Text.translatable(labelKey)} + ); + } + + private boolean isStartingGenderChoiceAllowed(int choiceBit) { + return (this.normalizeAllowedStartingGenderMask(this.allowedStartingGenderMask) & choiceBit) != 0; + } + + private int normalizeAllowedStartingGenderMask(int mask) { + int normalized = mask & 7; + return normalized == 0 ? 7 : normalized; + } + + private int toggleAllowedStartingGenderChoice(int mask, int choiceBit) { + int normalized = this.normalizeAllowedStartingGenderMask(mask); + int next = normalized ^ choiceBit; + return next == 0 ? normalized : next; + } + + private void updateAllowedStartingGenderButtons() { + if (this.allowStartingMaleButton != null) { + this.allowStartingMaleButton + .setMessage(this.startingGenderToggleLabel("config.needsofnature.player_gender.male", this.isStartingGenderChoiceAllowed(1))); + } + + if (this.allowStartingFemaleButton != null) { + this.allowStartingFemaleButton + .setMessage(this.startingGenderToggleLabel("config.needsofnature.player_gender.female", this.isStartingGenderChoiceAllowed(2))); + } + + if (this.allowStartingBothButton != null) { + this.allowStartingBothButton + .setMessage(this.startingGenderToggleLabel("config.needsofnature.player_gender.both", this.isStartingGenderChoiceAllowed(4))); + } + } + + private void updateResetButtons() { + if (this.resetInitialEnergyButton != null) { + this.resetInitialEnergyButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.initialEnergyField, this.defaults.getInitialEnergyMax()); + } + + if (this.resetGenderMaleButton != null) { + this.resetGenderMaleButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.genderMaleField, this.defaults.getGenderMaleChancePercent()); + } + + if (this.resetGenderFemaleButton != null) { + this.resetGenderFemaleButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.genderFemaleField, this.defaults.getGenderFemaleChancePercent()); + } + + if (this.resetGenderBothButton != null) { + this.resetGenderBothButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.genderBothField, this.defaults.getGenderBothChancePercent()); + } + + if (this.resetAllowPlayerGenderChangeButton != null) { + this.resetAllowPlayerGenderChangeButton.active = this.serverConfigEditable + && this.allowPlayerGenderChangeAnytime != this.defaults.allowPlayerGenderChangeAnytime(); + } + + if (this.resetRequirePlayerGenderSelectionButton != null) { + this.resetRequirePlayerGenderSelectionButton.active = this.serverConfigEditable + && this.requirePlayerGenderSelectionOnJoin != this.defaults.requirePlayerGenderSelectionOnJoin(); + } + + if (this.resetAllowedStartingGendersButton != null) { + this.resetAllowedStartingGendersButton.active = this.serverConfigEditable + && this.allowedStartingGenderMask != this.defaults.getAllowedStartingGenderMask(); + } + + if (this.allowPlayerGenderChangeButton != null) { + this.allowPlayerGenderChangeButton.active = this.serverConfigEditable; + } + + if (this.requirePlayerGenderSelectionButton != null) { + this.requirePlayerGenderSelectionButton.active = this.serverConfigEditable; + } + + if (this.allowStartingMaleButton != null) { + this.allowStartingMaleButton.active = this.serverConfigEditable; + } + + if (this.allowStartingFemaleButton != null) { + this.allowStartingFemaleButton.active = this.serverConfigEditable; + } + + if (this.allowStartingBothButton != null) { + this.allowStartingBothButton.active = this.serverConfigEditable; + } + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuHudConfigScreen.java b/src/main/java/com/nonid/integration/NonModMenuHudConfigScreen.java new file mode 100644 index 0000000..2d4908f --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuHudConfigScreen.java @@ -0,0 +1,653 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import com.nonid.client.NonHudOverlay; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.input.KeyInput; +import net.minecraft.text.Text; + +final class NonModMenuHudConfigScreen { + private NonModMenuHudConfigScreen() { + } + + static class UiConfigScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private static final int UI_MIN = -999; + private static final int UI_MAX = 999; + private int energyX; + private int energyY; + private int energyHeartX; + private int energyHeartY; + private int attackX; + private int attackY; + private int promptX; + private int promptY; + private int liquidX; + private int liquidY; + private boolean energyVisible; + private boolean energyHeartVisible; + private boolean attackVisible; + private boolean promptVisible; + private boolean liquidVisible; + private int appliedEnergyX; + private int appliedEnergyY; + private int appliedEnergyHeartX; + private int appliedEnergyHeartY; + private int appliedAttackX; + private int appliedAttackY; + private int appliedPromptX; + private int appliedPromptY; + private int appliedLiquidX; + private int appliedLiquidY; + private boolean appliedEnergyVisible; + private boolean appliedEnergyHeartVisible; + private boolean appliedAttackVisible; + private boolean appliedPromptVisible; + private boolean appliedLiquidVisible; + private NonModMenuHudConfigScreen.UiConfigScreen.HudElement selectedElement = NonModMenuHudConfigScreen.UiConfigScreen.HudElement.LIQUID; + private NonModMenuHudConfigScreen.UiConfigScreen.HudElement draggedElement = null; + private double dragStartMouseX; + private double dragStartMouseY; + private int dragStartOffsetX; + private int dragStartOffsetY; + private ButtonWidget saveButton; + private ButtonWidget cancelButton; + private ButtonWidget toggleSelectedVisibilityButton; + private ButtonWidget resetSelectedButton; + private ButtonWidget resetAllButton; + private ButtonWidget toolbarVisibilityButton; + private boolean toolbarVisible = true; + private NonHudOverlay.UiPreviewBounds lastBounds = NonHudOverlay.UiPreviewBounds.empty(); + + protected UiConfigScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.ui_title")); + this.parent = parent; + this.config = config; + this.energyX = config.getUiEnergyOffsetX(); + this.energyY = config.getUiEnergyOffsetY(); + this.energyHeartX = config.getUiEnergyHeartOffsetX(); + this.energyHeartY = config.getUiEnergyHeartOffsetY(); + this.attackX = config.getUiAttackOffsetX(); + this.attackY = config.getUiAttackOffsetY(); + this.promptX = config.getUiPromptOffsetX(); + this.promptY = config.getUiPromptOffsetY(); + this.liquidX = config.getUiLiquidOffsetX(); + this.liquidY = config.getUiLiquidOffsetY(); + this.energyVisible = config.isUiEnergyVisible(); + this.energyHeartVisible = config.isUiEnergyHeartVisible(); + this.attackVisible = config.isUiAttackVisible(); + this.promptVisible = config.isUiPromptVisible(); + this.liquidVisible = config.isUiLiquidVisible(); + this.appliedEnergyX = this.energyX; + this.appliedEnergyY = this.energyY; + this.appliedEnergyHeartX = this.energyHeartX; + this.appliedEnergyHeartY = this.energyHeartY; + this.appliedAttackX = this.attackX; + this.appliedAttackY = this.attackY; + this.appliedPromptX = this.promptX; + this.appliedPromptY = this.promptY; + this.appliedLiquidX = this.liquidX; + this.appliedLiquidY = this.liquidY; + this.appliedEnergyVisible = this.energyVisible; + this.appliedEnergyHeartVisible = this.energyHeartVisible; + this.appliedAttackVisible = this.attackVisible; + this.appliedPromptVisible = this.promptVisible; + this.appliedLiquidVisible = this.liquidVisible; + } + + protected void init() { + NonHudOverlay.setUiPreviewEnabled(true); + this.updatePreviewOffsets(); + int buttonY = 8; + this.saveButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> { + this.saveOffsets(); + NonHudOverlay.setUiPreviewEnabled(false); + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(0, buttonY, 48, 18).build(); + NonModMenuScreens.setTooltip(this.saveButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(this.saveButton); + this.toggleSelectedVisibilityButton = ButtonWidget.builder(this.toggleSelectedVisibilityLabel(), button -> { + this.setVisible(this.selectedElement, !this.isVisible(this.selectedElement)); + this.updatePreviewOffsets(); + this.updateButtons(); + }).dimensions(0, buttonY, 86, 18).build(); + NonModMenuScreens.setTooltip(this.toggleSelectedVisibilityButton, "config.needsofnature.tooltip.ui_toggle_selected_visibility"); + this.addDrawableChild(this.toggleSelectedVisibilityButton); + this.resetSelectedButton = ButtonWidget.builder(Text.translatable("config.needsofnature.ui_reset_selected"), button -> { + this.resetElement(this.selectedElement); + this.updatePreviewOffsets(); + this.updateButtons(); + }).dimensions(0, buttonY, 86, 18).build(); + this.addDrawableChild(this.resetSelectedButton); + this.resetAllButton = ButtonWidget.builder(Text.translatable("config.needsofnature.ui_reset_all"), button -> { + this.resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY); + this.resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY_HEART); + this.resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ATTACK); + this.resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement.PROMPT); + this.resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement.LIQUID); + this.updatePreviewOffsets(); + this.updateButtons(); + }).dimensions(0, buttonY, 62, 18).build(); + this.addDrawableChild(this.resetAllButton); + this.cancelButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> { + this.resetToApplied(); + NonHudOverlay.setUiPreviewEnabled(false); + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(0, buttonY, 54, 18).build(); + NonModMenuScreens.setTooltip(this.cancelButton, "config.needsofnature.tooltip.done_unsaved"); + this.addDrawableChild(this.cancelButton); + this.toolbarVisibilityButton = ButtonWidget.builder(this.toolbarVisibilityLabel(), button -> { + this.toolbarVisible = !this.toolbarVisible; + this.updateButtons(); + }).dimensions(0, buttonY, 18, 18).build(); + NonModMenuScreens.setTooltip(this.toolbarVisibilityButton, "config.needsofnature.tooltip.ui_toolbar_visibility"); + this.addDrawableChild(this.toolbarVisibilityButton); + this.updateButtons(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updatePreviewOffsets(); + super.render(context, mouseX, mouseY, deltaTicks); + this.lastBounds = NonHudOverlay.getUiPreviewBounds(context); + this.drawDragOverlays(context, mouseX, mouseY); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 12, 16777215); + Text help = Text.translatable("config.needsofnature.ui_drag_help"); + context.drawCenteredTextWithShadow(this.textRenderer, help, this.width / 2, 24, -2039584); + Text selected = this.isVisible(this.selectedElement) + ? Text.translatable("config.needsofnature.ui_selected", new Object[]{Text.translatable(this.selectedElement.labelKey)}) + : Text.translatable("config.needsofnature.ui_selected_hidden", new Object[]{Text.translatable(this.selectedElement.labelKey)}); + context.drawCenteredTextWithShadow(this.textRenderer, selected, this.width / 2, 36, this.selectedElement.color); + this.updateButtons(); + } + + public void renderBackground(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + } + + public boolean mouseClicked(Click click, boolean doubled) { + if (super.mouseClicked(click, doubled)) { + return true; + } + + if (click.button() != 0) { + return false; + } + + NonModMenuHudConfigScreen.UiConfigScreen.HudElement hit = this.hitTest(click.x(), click.y()); + if (hit == null) { + return false; + } + + this.selectedElement = hit; + this.draggedElement = hit; + this.dragStartMouseX = click.x(); + this.dragStartMouseY = click.y(); + this.dragStartOffsetX = this.offsetX(hit); + this.dragStartOffsetY = this.offsetY(hit); + this.updateButtons(); + return true; + } + + public boolean mouseDragged(Click click, double offsetX, double offsetY) { + if (this.draggedElement != null && click.button() == 0) { + int nextX = this.clampUiOffset(this.dragStartOffsetX + (int)Math.round(click.x() - this.dragStartMouseX)); + int nextY = this.clampUiOffset(this.dragStartOffsetY + (int)Math.round(click.y() - this.dragStartMouseY)); + this.setOffset(this.draggedElement, nextX, nextY); + this.updatePreviewOffsets(); + this.updateButtons(); + return true; + } else { + return super.mouseDragged(click, offsetX, offsetY); + } + } + + public boolean mouseReleased(Click click) { + if (click.button() == 0 && this.draggedElement != null) { + this.draggedElement = null; + this.updateButtons(); + return true; + } else { + return super.mouseReleased(click); + } + } + + public boolean keyPressed(KeyInput input) { + int step = (input.modifiers() & 1) != 0 ? 5 : 1; + int dx = 0; + int dy = 0; + int keyCode = input.key(); + if (keyCode == 263) { + dx = -step; + } + + if (keyCode == 262) { + dx = step; + } + + if (keyCode == 265) { + dy = -step; + } + + if (keyCode == 264) { + dy = step; + } + + if (dx == 0 && dy == 0) { + return super.keyPressed(input); + } + + this.setOffset( + this.selectedElement, this.clampUiOffset(this.offsetX(this.selectedElement) + dx), this.clampUiOffset(this.offsetY(this.selectedElement) + dy) + ); + this.updatePreviewOffsets(); + this.updateButtons(); + return true; + } + + public void close() { + this.resetToApplied(); + NonHudOverlay.setUiPreviewEnabled(false); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void drawDragOverlays(DrawContext context, int mouseX, int mouseY) { + this.drawElementBox(context, this.lastBounds.energy(), NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY, mouseX, mouseY); + this.drawElementBox(context, this.lastBounds.energyHeart(), NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY_HEART, mouseX, mouseY); + this.drawElementBox(context, this.lastBounds.attack(), NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ATTACK, mouseX, mouseY); + this.drawElementBox(context, this.lastBounds.prompt(), NonModMenuHudConfigScreen.UiConfigScreen.HudElement.PROMPT, mouseX, mouseY); + this.drawElementBox(context, this.lastBounds.liquid(), NonModMenuHudConfigScreen.UiConfigScreen.HudElement.LIQUID, mouseX, mouseY); + } + + private void drawElementBox( + DrawContext context, NonHudOverlay.Rect rawRect, NonModMenuHudConfigScreen.UiConfigScreen.HudElement element, int mouseX, int mouseY + ) { + NonHudOverlay.Rect rect = rawRect.padded(4); + boolean hovered = rect.contains(mouseX, mouseY); + boolean active = element == this.selectedElement || element == this.draggedElement || hovered; + boolean visible = this.isVisible(element); + if (active || !visible) { + int color = visible ? element.color : -8355712; + int fill = color & 16777215 | (!hovered && element != this.draggedElement ? (visible ? 402653184 : 570425344) : 855638016); + context.fill(rect.x(), rect.y(), rect.x() + rect.width(), rect.y() + rect.height(), fill); + this.drawOutline(context, rect, color); + Text label = visible + ? Text.translatable(element.labelKey) + : Text.translatable("config.needsofnature.ui_hidden_label", new Object[]{Text.translatable(element.labelKey)}); + context.drawTextWithShadow(this.textRenderer, label, rect.x(), Math.max(0, rect.y() - 10), color); + } + } + + private void drawOutline(DrawContext context, NonHudOverlay.Rect rect, int color) { + int x1 = rect.x(); + int y1 = rect.y(); + int x2 = rect.x() + rect.width(); + int y2 = rect.y() + rect.height(); + context.fill(x1, y1, x2, y1 + 1, color); + context.fill(x1, y2 - 1, x2, y2, color); + context.fill(x1, y1, x1 + 1, y2, color); + context.fill(x2 - 1, y1, x2, y2, color); + } + + private NonModMenuHudConfigScreen.UiConfigScreen.HudElement hitTest(double mouseX, double mouseY) { + if (this.lastBounds.liquid().padded(6).contains(mouseX, mouseY)) { + return NonModMenuHudConfigScreen.UiConfigScreen.HudElement.LIQUID; + } else if (this.lastBounds.prompt().padded(6).contains(mouseX, mouseY)) { + return NonModMenuHudConfigScreen.UiConfigScreen.HudElement.PROMPT; + } else if (this.lastBounds.attack().padded(6).contains(mouseX, mouseY)) { + return NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ATTACK; + } else if (this.lastBounds.energyHeart().padded(6).contains(mouseX, mouseY)) { + return NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY_HEART; + } else { + return this.lastBounds.energy().padded(6).contains(mouseX, mouseY) ? NonModMenuHudConfigScreen.UiConfigScreen.HudElement.ENERGY : null; + } + } + + private int offsetX(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.energyX; + case ENERGY_HEART -> this.energyHeartX; + case ATTACK -> this.attackX; + case PROMPT -> this.promptX; + case LIQUID -> this.liquidX; + }; + } + + private int offsetY(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.energyY; + case ENERGY_HEART -> this.energyHeartY; + case ATTACK -> this.attackY; + case PROMPT -> this.promptY; + case LIQUID -> this.liquidY; + }; + } + + private void setOffset(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element, int x, int y) { + switch (element) { + case ENERGY: + this.energyX = x; + this.energyY = y; + break; + case ENERGY_HEART: + this.energyHeartX = x; + this.energyHeartY = y; + break; + case ATTACK: + this.attackX = x; + this.attackY = y; + break; + case PROMPT: + this.promptX = x; + this.promptY = y; + break; + case LIQUID: + this.liquidX = x; + this.liquidY = y; + } + } + + private boolean isVisible(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.energyVisible; + case ENERGY_HEART -> this.energyHeartVisible; + case ATTACK -> this.attackVisible; + case PROMPT -> this.promptVisible; + case LIQUID -> this.liquidVisible; + }; + } + + private void setVisible(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element, boolean visible) { + switch (element) { + case ENERGY: + this.energyVisible = visible; + break; + case ENERGY_HEART: + this.energyHeartVisible = visible; + break; + case ATTACK: + this.attackVisible = visible; + break; + case PROMPT: + this.promptVisible = visible; + break; + case LIQUID: + this.liquidVisible = visible; + } + } + + private void resetElement(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + switch (element) { + case ENERGY: + this.setOffset(element, this.defaults.getUiEnergyOffsetX(), this.defaults.getUiEnergyOffsetY()); + this.setVisible(element, this.defaults.isUiEnergyVisible()); + break; + case ENERGY_HEART: + this.setOffset(element, this.defaults.getUiEnergyHeartOffsetX(), this.defaults.getUiEnergyHeartOffsetY()); + this.setVisible(element, this.defaults.isUiEnergyHeartVisible()); + break; + case ATTACK: + this.setOffset(element, this.defaults.getUiAttackOffsetX(), this.defaults.getUiAttackOffsetY()); + this.setVisible(element, this.defaults.isUiAttackVisible()); + break; + case PROMPT: + this.setOffset(element, this.defaults.getUiPromptOffsetX(), this.defaults.getUiPromptOffsetY()); + this.setVisible(element, this.defaults.isUiPromptVisible()); + break; + case LIQUID: + this.setOffset(element, this.defaults.getUiLiquidOffsetX(), this.defaults.getUiLiquidOffsetY()); + this.setVisible(element, this.defaults.isUiLiquidVisible()); + } + } + + private void saveOffsets() { + this.config.setUiEnergyOffsetX(this.energyX); + this.config.setUiEnergyOffsetY(this.energyY); + this.config.setUiEnergyHeartOffsetX(this.energyHeartX); + this.config.setUiEnergyHeartOffsetY(this.energyHeartY); + this.config.setUiAttackOffsetX(this.attackX); + this.config.setUiAttackOffsetY(this.attackY); + this.config.setUiPromptOffsetX(this.promptX); + this.config.setUiPromptOffsetY(this.promptY); + this.config.setUiLiquidOffsetX(this.liquidX); + this.config.setUiLiquidOffsetY(this.liquidY); + this.config.setUiEnergyVisible(this.energyVisible); + this.config.setUiEnergyHeartVisible(this.energyHeartVisible); + this.config.setUiAttackVisible(this.attackVisible); + this.config.setUiPromptVisible(this.promptVisible); + this.config.setUiLiquidVisible(this.liquidVisible); + this.config.save(); + this.appliedEnergyX = this.energyX; + this.appliedEnergyY = this.energyY; + this.appliedEnergyHeartX = this.energyHeartX; + this.appliedEnergyHeartY = this.energyHeartY; + this.appliedAttackX = this.attackX; + this.appliedAttackY = this.attackY; + this.appliedPromptX = this.promptX; + this.appliedPromptY = this.promptY; + this.appliedLiquidX = this.liquidX; + this.appliedLiquidY = this.liquidY; + this.appliedEnergyVisible = this.energyVisible; + this.appliedEnergyHeartVisible = this.energyHeartVisible; + this.appliedAttackVisible = this.attackVisible; + this.appliedPromptVisible = this.promptVisible; + this.appliedLiquidVisible = this.liquidVisible; + } + + private void resetToApplied() { + this.energyX = this.appliedEnergyX; + this.energyY = this.appliedEnergyY; + this.energyHeartX = this.appliedEnergyHeartX; + this.energyHeartY = this.appliedEnergyHeartY; + this.attackX = this.appliedAttackX; + this.attackY = this.appliedAttackY; + this.promptX = this.appliedPromptX; + this.promptY = this.appliedPromptY; + this.liquidX = this.appliedLiquidX; + this.liquidY = this.appliedLiquidY; + this.energyVisible = this.appliedEnergyVisible; + this.energyHeartVisible = this.appliedEnergyHeartVisible; + this.attackVisible = this.appliedAttackVisible; + this.promptVisible = this.appliedPromptVisible; + this.liquidVisible = this.appliedLiquidVisible; + this.updatePreviewOffsets(); + } + + private void updatePreviewOffsets() { + NonHudOverlay.setUiPreviewOffsets( + this.energyX, + this.energyY, + this.attackX, + this.attackY, + this.promptX, + this.promptY, + this.liquidX, + this.liquidY, + this.energyHeartX, + this.energyHeartY, + this.energyVisible, + this.energyHeartVisible, + this.attackVisible, + this.promptVisible, + this.liquidVisible + ); + } + + private boolean hasPendingUiChanges() { + return this.energyX != this.appliedEnergyX + || this.energyY != this.appliedEnergyY + || this.energyHeartX != this.appliedEnergyHeartX + || this.energyHeartY != this.appliedEnergyHeartY + || this.attackX != this.appliedAttackX + || this.attackY != this.appliedAttackY + || this.promptX != this.appliedPromptX + || this.promptY != this.appliedPromptY + || this.liquidX != this.appliedLiquidX + || this.liquidY != this.appliedLiquidY + || this.energyVisible != this.appliedEnergyVisible + || this.energyHeartVisible != this.appliedEnergyHeartVisible + || this.attackVisible != this.appliedAttackVisible + || this.promptVisible != this.appliedPromptVisible + || this.liquidVisible != this.appliedLiquidVisible; + } + + private Text toolbarVisibilityLabel() { + return Text.literal(this.toolbarVisible ? "H" : "S"); + } + + private Text toggleSelectedVisibilityLabel() { + return Text.translatable(this.isVisible(this.selectedElement) ? "config.needsofnature.ui_hide_selected" : "config.needsofnature.ui_show_selected"); + } + + private void updateButtons() { + this.updateToolbarLayout(); + boolean pending = this.hasPendingUiChanges(); + if (this.saveButton != null) { + this.saveButton.active = pending; + } + + if (this.toggleSelectedVisibilityButton != null) { + this.toggleSelectedVisibilityButton.setMessage(this.toggleSelectedVisibilityLabel()); + } + + if (this.resetSelectedButton != null) { + this.resetSelectedButton.active = this.offsetX(this.selectedElement) != this.defaultX(this.selectedElement) + || this.offsetY(this.selectedElement) != this.defaultY(this.selectedElement) + || this.isVisible(this.selectedElement) != this.defaultVisible(this.selectedElement); + } + + if (this.resetAllButton != null) { + this.resetAllButton.active = this.energyX != this.defaults.getUiEnergyOffsetX() + || this.energyY != this.defaults.getUiEnergyOffsetY() + || this.energyHeartX != this.defaults.getUiEnergyHeartOffsetX() + || this.energyHeartY != this.defaults.getUiEnergyHeartOffsetY() + || this.attackX != this.defaults.getUiAttackOffsetX() + || this.attackY != this.defaults.getUiAttackOffsetY() + || this.promptX != this.defaults.getUiPromptOffsetX() + || this.promptY != this.defaults.getUiPromptOffsetY() + || this.liquidX != this.defaults.getUiLiquidOffsetX() + || this.liquidY != this.defaults.getUiLiquidOffsetY() + || this.energyVisible != this.defaults.isUiEnergyVisible() + || this.energyHeartVisible != this.defaults.isUiEnergyHeartVisible() + || this.attackVisible != this.defaults.isUiAttackVisible() + || this.promptVisible != this.defaults.isUiPromptVisible() + || this.liquidVisible != this.defaults.isUiLiquidVisible(); + } + + if (this.saveButton != null) { + this.saveButton.visible = this.toolbarVisible; + } + + if (this.cancelButton != null) { + this.cancelButton.visible = this.toolbarVisible; + } + + if (this.toggleSelectedVisibilityButton != null) { + this.toggleSelectedVisibilityButton.visible = this.toolbarVisible; + } + + if (this.resetSelectedButton != null) { + this.resetSelectedButton.visible = this.toolbarVisible; + } + + if (this.resetAllButton != null) { + this.resetAllButton.visible = this.toolbarVisible; + } + + if (this.toolbarVisibilityButton != null) { + this.toolbarVisibilityButton.visible = true; + this.toolbarVisibilityButton.setMessage(this.toolbarVisibilityLabel()); + } + } + + private void updateToolbarLayout() { + if (this.toolbarVisibilityButton != null) { + int y = 8; + int gap = 3; + int eyeW = this.toolbarVisibilityButton.getWidth(); + int right = this.width - 8; + this.toolbarVisibilityButton.setPosition(right - eyeW, y); + if (this.toolbarVisible) { + int nextY = y + this.toolbarVisibilityButton.getHeight() + gap; + if (this.toggleSelectedVisibilityButton != null) { + this.toggleSelectedVisibilityButton.setPosition(right - this.toggleSelectedVisibilityButton.getWidth(), nextY); + nextY += this.toggleSelectedVisibilityButton.getHeight() + gap; + } + + if (this.resetSelectedButton != null) { + this.resetSelectedButton.setPosition(right - this.resetSelectedButton.getWidth(), nextY); + nextY += this.resetSelectedButton.getHeight() + gap; + } + + if (this.resetAllButton != null) { + this.resetAllButton.setPosition(right - this.resetAllButton.getWidth(), nextY); + nextY += this.resetAllButton.getHeight() + gap; + } + + if (this.saveButton != null) { + this.saveButton.setPosition(right - this.saveButton.getWidth(), nextY); + nextY += this.saveButton.getHeight() + gap; + } + + if (this.cancelButton != null) { + this.cancelButton.setPosition(right - this.cancelButton.getWidth(), nextY); + } + } + } + } + + private int defaultX(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.defaults.getUiEnergyOffsetX(); + case ENERGY_HEART -> this.defaults.getUiEnergyHeartOffsetX(); + case ATTACK -> this.defaults.getUiAttackOffsetX(); + case PROMPT -> this.defaults.getUiPromptOffsetX(); + case LIQUID -> this.defaults.getUiLiquidOffsetX(); + }; + } + + private int defaultY(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.defaults.getUiEnergyOffsetY(); + case ENERGY_HEART -> this.defaults.getUiEnergyHeartOffsetY(); + case ATTACK -> this.defaults.getUiAttackOffsetY(); + case PROMPT -> this.defaults.getUiPromptOffsetY(); + case LIQUID -> this.defaults.getUiLiquidOffsetY(); + }; + } + + private boolean defaultVisible(NonModMenuHudConfigScreen.UiConfigScreen.HudElement element) { + return switch (element) { + case ENERGY -> this.defaults.isUiEnergyVisible(); + case ENERGY_HEART -> this.defaults.isUiEnergyHeartVisible(); + case ATTACK -> this.defaults.isUiAttackVisible(); + case PROMPT -> this.defaults.isUiPromptVisible(); + case LIQUID -> this.defaults.isUiLiquidVisible(); + }; + } + + private int clampUiOffset(int value) { + return Math.max(-999, Math.min(999, value)); + } + + private enum HudElement { + ENERGY("config.needsofnature.ui_energy", -8532993), + ENERGY_HEART("config.needsofnature.ui_energy_heart", -33333), + ATTACK("config.needsofnature.ui_attack", -38037), + PROMPT("config.needsofnature.ui_prompt", -11930), + LIQUID("config.needsofnature.ui_liquid", -10616907); + + private final String labelKey; + private final int color; + + HudElement(String labelKey, int color) { + this.labelKey = labelKey; + this.color = color; + } + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntegration.java b/src/main/java/com/nonid/integration/NonModMenuIntegration.java new file mode 100644 index 0000000..4709f25 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntegration.java @@ -0,0 +1,10 @@ +package com.nonid.integration; + +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; + +public class NonModMenuIntegration implements ModMenuApi { + public ConfigScreenFactory getModConfigScreenFactory() { + return NonModMenuScreens::createConfigScreen; + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntifaceInstallScreen.java b/src/main/java/com/nonid/integration/NonModMenuIntifaceInstallScreen.java new file mode 100644 index 0000000..d39842e --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntifaceInstallScreen.java @@ -0,0 +1,97 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import com.nonid.client.intiface.NonIntifaceDependencyManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; + +final class NonModMenuIntifaceInstallScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final boolean enableAfterInstall; + private ButtonWidget downloadButton; + + NonModMenuIntifaceInstallScreen(Screen parent, NonConfig config, boolean enableAfterInstall) { + super(Text.translatable("config.needsofnature.intiface.libs.install_title")); + this.parent = parent; + this.config = config; + this.enableAfterInstall = enableAfterInstall; + } + + protected void init() { + int centerX = this.width / 2; + this.downloadButton = ButtonWidget.builder( + Text.translatable( + NonIntifaceDependencyManager.generatedBundleOutdated() + ? "config.needsofnature.intiface.libs.redownload" + : "config.needsofnature.intiface.libs.download" + ), + button -> this.startInstall() + ) + .dimensions(centerX - 155, this.height - 52, 150, 20) + .build(); + this.addDrawableChild(this.downloadButton); + ButtonWidget cancelButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 5, this.height - 52, 150, 20) + .build(); + this.addDrawableChild(cancelButton); + ButtonWidget doneButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent) + ) + .dimensions(centerX - 75, this.height - 28, 150, 20) + .build(); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 28, 16777215); + int y = 58; + this.drawCentered(context, Text.translatable("config.needsofnature.intiface.libs.install_line_1"), y); + y += 14; + this.drawCentered( + context, Text.translatable("config.needsofnature.intiface.libs.install_line_2", new Object[]{NonIntifaceDependencyManager.totalSizeMbText()}), y + ); + y += 14; + this.drawCentered(context, Text.translatable("config.needsofnature.intiface.libs.install_line_3"), y); + y += 22; + this.drawCentered(context, NonIntifaceDependencyManager.statusText(), y); + if (this.downloadButton != null) { + this.downloadButton + .setMessage( + Text.translatable( + NonIntifaceDependencyManager.generatedBundleOutdated() + ? "config.needsofnature.intiface.libs.redownload" + : "config.needsofnature.intiface.libs.download" + ) + ); + this.downloadButton.active = !NonIntifaceDependencyManager.installing() + && !NonIntifaceDependencyManager.restartRequired() + && NonIntifaceDependencyManager.installRequired(); + } + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void startInstall() { + if (this.downloadButton != null) { + this.downloadButton.active = false; + } + + NonIntifaceDependencyManager.installAsync(() -> { + if (this.enableAfterInstall && NonIntifaceDependencyManager.restartRequired()) { + this.config.setIntifaceEnabled(true); + this.config.save(); + } + }); + } + + private void drawCentered(DrawContext context, Text text, int y) { + context.drawCenteredTextWithShadow(this.textRenderer, text, this.width / 2, y, -2039584); + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntifaceScreen.java b/src/main/java/com/nonid/integration/NonModMenuIntifaceScreen.java new file mode 100644 index 0000000..45fdd28 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntifaceScreen.java @@ -0,0 +1,323 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import com.nonid.client.intiface.NonIntifaceBridge; +import com.nonid.client.intiface.NonIntifaceDependencyManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +final class NonModMenuIntifaceScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean enabled; + private boolean autoConnectOnWorldJoin; + private boolean stopOnDisconnect; + private String serverUrl; + private ButtonWidget enabledButton; + private ButtonWidget resetEnabledButton; + private ButtonWidget autoConnectButton; + private ButtonWidget resetAutoConnectButton; + private ButtonWidget stopOnDisconnectButton; + private ButtonWidget resetStopOnDisconnectButton; + private TextFieldWidget serverUrlField; + private ButtonWidget resetServerUrlButton; + private ButtonWidget statusButton; + private ButtonWidget connectButton; + private ButtonWidget scanButton; + private ButtonWidget testButton; + + NonModMenuIntifaceScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.intiface_title")); + this.parent = parent; + this.config = config; + this.loadFromConfig(); + } + + protected void init() { + if (this.serverUrlField != null) { + this.serverUrl = this.serverUrlField.getText(); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_connection"))); + boolean librariesUsable = NonIntifaceDependencyManager.librariesUsable(); + this.enabledButton = ButtonWidget.builder(this.toggleText(this.enabled), button -> { + if (!this.enabled && !librariesUsable) { + MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceInstallScreen(this, this.config, true)); + } else { + this.enabled = !this.enabled; + button.setMessage(this.toggleText(this.enabled)); + this.updateResetButtons(); + } + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.enabledButton, "config.needsofnature.tooltip.intiface_enabled"); + this.resetEnabledButton = this.resetButton(() -> { + this.enabled = this.defaults.isIntifaceEnabled(); + this.enabledButton.setMessage(this.toggleText(this.enabled)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_enabled"), + this.enabledButton, + this.resetEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_enabled") + ) + ); + if (!librariesUsable) { + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_libraries"))); + this.statusButton = ButtonWidget.builder(NonIntifaceDependencyManager.statusText(), button -> {}).dimensions(0, 0, 220, 20).build(); + this.statusButton.active = false; + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, this.statusButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_libraries_status") + ) + ); + ButtonWidget installButton = ButtonWidget.builder( + Text.translatable( + NonIntifaceDependencyManager.restartRequired() + ? "config.needsofnature.intiface.libs.restart_required" + : ( + NonIntifaceDependencyManager.generatedBundleOutdated() + ? "config.needsofnature.intiface.libs.redownload" + : "config.needsofnature.intiface.libs.install" + ) + ), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceInstallScreen(this, this.config, false)) + ) + .dimensions(0, 0, 220, 20) + .build(); + installButton.active = !NonIntifaceDependencyManager.installing() + && !NonIntifaceDependencyManager.restartRequired() + && NonIntifaceDependencyManager.installRequired(); + NonModMenuScreens.setTooltip(installButton, "config.needsofnature.tooltip.intiface_libraries_install"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, installButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_libraries_install") + ) + ); + this.updateResetButtons(); + this.addDoneButton(); + } else { + this.serverUrlField = new TextFieldWidget(this.textRenderer, 0, 0, 180, 20, Text.literal("")); + this.serverUrlField.setMaxLength(128); + this.serverUrlField.setText(this.serverUrl); + this.serverUrlField.setChangedListener(value -> this.updateResetButtons()); + this.resetServerUrlButton = this.resetButton(() -> this.serverUrlField.setText(this.defaults.getIntifaceServerUrl())); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_server_url"), + this.serverUrlField, + this.resetServerUrlButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_server_url") + ) + ); + this.autoConnectButton = ButtonWidget.builder(this.toggleText(this.autoConnectOnWorldJoin), button -> { + this.autoConnectOnWorldJoin = !this.autoConnectOnWorldJoin; + button.setMessage(this.toggleText(this.autoConnectOnWorldJoin)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.autoConnectButton, "config.needsofnature.tooltip.intiface_auto_connect"); + this.resetAutoConnectButton = this.resetButton(() -> { + this.autoConnectOnWorldJoin = this.defaults.isIntifaceAutoConnectOnWorldJoin(); + this.autoConnectButton.setMessage(this.toggleText(this.autoConnectOnWorldJoin)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_auto_connect"), + this.autoConnectButton, + this.resetAutoConnectButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_auto_connect") + ) + ); + this.stopOnDisconnectButton = ButtonWidget.builder(this.toggleText(this.stopOnDisconnect), button -> { + this.stopOnDisconnect = !this.stopOnDisconnect; + button.setMessage(this.toggleText(this.stopOnDisconnect)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.stopOnDisconnectButton, "config.needsofnature.tooltip.intiface_stop_on_disconnect"); + this.resetStopOnDisconnectButton = this.resetButton(() -> { + this.stopOnDisconnect = this.defaults.isIntifaceStopOnDisconnect(); + this.stopOnDisconnectButton.setMessage(this.toggleText(this.stopOnDisconnect)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_stop_on_disconnect"), + this.stopOnDisconnectButton, + this.resetStopOnDisconnectButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stop_on_disconnect") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_controls"))); + this.statusButton = ButtonWidget.builder(NonIntifaceBridge.statusText(this.config), button -> {}).dimensions(0, 0, 220, 20).build(); + this.statusButton.active = false; + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, this.statusButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_status") + ) + ); + this.connectButton = ButtonWidget.builder(Text.translatable("config.needsofnature.intiface_connect"), button -> { + if (NonIntifaceBridge.isConnected()) { + NonIntifaceBridge.disconnect(); + } else { + this.saveValues(); + NonIntifaceBridge.connect(this.config); + } + }).dimensions(0, 0, 106, 20).build(); + this.scanButton = ButtonWidget.builder(Text.translatable("config.needsofnature.intiface_scan"), button -> { + this.saveValues(); + NonIntifaceBridge.startScanning(this.config); + }).dimensions(0, 0, 106, 20).build(); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_connect_scan"), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_connect_scan"), + this.connectButton, + this.scanButton + ) + ); + this.testButton = ButtonWidget.builder(Text.translatable("config.needsofnature.intiface_test"), button -> { + this.saveValues(); + NonIntifaceBridge.testPulse(this.config, false); + }).dimensions(0, 0, 216, 20).build(); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_test_pulse"), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_test_pulse"), + this.testButton + ) + ); + settingsList.addEntryRow( + SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_toy_settings")) + ); + ButtonWidget tuningButton = ButtonWidget.builder(Text.translatable("config.needsofnature.intiface_toy_settings"), button -> { + this.saveValues(); + MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceTuningScreen(this, this.config)); + }).dimensions(0, 0, 220, 20).build(); + NonModMenuScreens.setTooltip(tuningButton, "config.needsofnature.tooltip.intiface_toy_settings"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, tuningButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_toy_settings") + ) + ); + this.updateResetButtons(); + this.updateConnectionButtons(); + this.addDoneButton(); + } + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + if (this.statusButton != null) { + this.statusButton + .setMessage(NonIntifaceDependencyManager.librariesUsable() ? NonIntifaceBridge.statusText(this.config) : NonIntifaceDependencyManager.statusText()); + } + + this.updateConnectionButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.saveValues(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void saveValues() { + this.serverUrl = this.serverUrlField == null ? this.serverUrl : this.serverUrlField.getText(); + this.config.setIntifaceEnabled(this.enabled); + this.config.setIntifaceAutoConnectOnWorldJoin(this.autoConnectOnWorldJoin); + this.config.setIntifaceStopOnDisconnect(this.stopOnDisconnect); + this.config.setIntifaceServerUrl(this.serverUrl); + this.config.save(); + this.loadFromConfig(); + if (!this.enabled) { + NonIntifaceBridge.stopAll(); + NonIntifaceBridge.disconnect(); + } + } + + private void loadFromConfig() { + this.enabled = this.config.isIntifaceEnabled(); + this.autoConnectOnWorldJoin = this.config.isIntifaceAutoConnectOnWorldJoin(); + this.stopOnDisconnect = this.config.isIntifaceStopOnDisconnect(); + this.serverUrl = this.config.getIntifaceServerUrl(); + } + + private void addDoneButton() { + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + private ButtonWidget resetButton(Runnable action) { + ButtonWidget button = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), ignored -> { + if (action != null) { + action.run(); + } + + this.updateResetButtons(); + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(button, "config.needsofnature.tooltip.reset"); + return button; + } + + private void updateResetButtons() { + if (this.resetEnabledButton != null) { + this.resetEnabledButton.active = this.enabled != this.defaults.isIntifaceEnabled(); + } + + if (this.resetAutoConnectButton != null) { + this.resetAutoConnectButton.active = this.autoConnectOnWorldJoin != this.defaults.isIntifaceAutoConnectOnWorldJoin(); + } + + if (this.resetStopOnDisconnectButton != null) { + this.resetStopOnDisconnectButton.active = this.stopOnDisconnect != this.defaults.isIntifaceStopOnDisconnect(); + } + + if (this.resetServerUrlButton != null && this.serverUrlField != null) { + this.resetServerUrlButton.active = !this.serverUrlField.getText().equals(this.defaults.getIntifaceServerUrl()); + } + } + + private void updateConnectionButtons() { + boolean connected = NonIntifaceBridge.isConnected(); + if (this.connectButton != null) { + this.connectButton.setMessage(Text.translatable(connected ? "config.needsofnature.intiface_disconnect" : "config.needsofnature.intiface_connect")); + } + + if (this.scanButton != null) { + this.scanButton.active = connected; + } + + if (this.testButton != null) { + this.testButton.active = connected; + } + } + + private Text toggleText(boolean value) { + return Text.translatable(value ? "options.on" : "options.off"); + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntifaceStrokerOscillatorScreen.java b/src/main/java/com/nonid/integration/NonModMenuIntifaceStrokerOscillatorScreen.java new file mode 100644 index 0000000..a434aac --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntifaceStrokerOscillatorScreen.java @@ -0,0 +1,240 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import java.util.Locale; +import java.util.function.DoubleConsumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; + +final class NonModMenuIntifaceStrokerOscillatorScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int oscillatorRegularSpeedPercent; + private int oscillatorPeakSpeedPercent; + private int strokerMinDistancePercent; + private int strokerMaxDistancePercent; + private int strokerRegularMoveDurationMs; + private int strokerPeakMoveDurationMs; + private FloatValueSliderWidget oscillatorRegularSlider; + private ButtonWidget resetOscillatorRegularButton; + private FloatValueSliderWidget oscillatorPeakSlider; + private ButtonWidget resetOscillatorPeakButton; + private FloatValueSliderWidget strokerMinSlider; + private ButtonWidget resetStrokerMinButton; + private FloatValueSliderWidget strokerMaxSlider; + private ButtonWidget resetStrokerMaxButton; + private FloatValueSliderWidget strokerRegularSlider; + private ButtonWidget resetStrokerRegularButton; + private FloatValueSliderWidget strokerPeakSlider; + private ButtonWidget resetStrokerPeakButton; + + NonModMenuIntifaceStrokerOscillatorScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.intiface_stroker_oscillator_title")); + this.parent = parent; + this.config = config; + this.loadFromConfig(); + } + + protected void init() { + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_oscillator"))); + this.oscillatorRegularSlider = this.percentSlider(this.oscillatorRegularSpeedPercent, value -> { + this.oscillatorRegularSpeedPercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetOscillatorRegularButton = this.resetButton(() -> { + this.oscillatorRegularSpeedPercent = this.defaults.getIntifaceOscillatorRegularSpeedPercent(); + this.oscillatorRegularSlider.setFloatValue(this.oscillatorRegularSpeedPercent); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_oscillator_regular_speed"), + this.oscillatorRegularSlider, + this.resetOscillatorRegularButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_oscillator_regular_speed") + ) + ); + this.oscillatorPeakSlider = this.percentSlider(this.oscillatorPeakSpeedPercent, value -> { + this.oscillatorPeakSpeedPercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetOscillatorPeakButton = this.resetButton(() -> { + this.oscillatorPeakSpeedPercent = this.defaults.getIntifaceOscillatorPeakSpeedPercent(); + this.oscillatorPeakSlider.setFloatValue(this.oscillatorPeakSpeedPercent); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_oscillator_peak_speed"), + this.oscillatorPeakSlider, + this.resetOscillatorPeakButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_oscillator_peak_speed") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_stroker"))); + this.strokerMaxSlider = this.percentSlider(this.strokerMaxDistancePercent, value -> { + this.strokerMaxDistancePercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetStrokerMaxButton = this.resetButton(() -> { + this.strokerMaxDistancePercent = this.defaults.getIntifaceStrokerMaxDistancePercent(); + this.strokerMaxSlider.setFloatValue(this.strokerMaxDistancePercent); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_stroker_max_distance"), + this.strokerMaxSlider, + this.resetStrokerMaxButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stroker_max_distance") + ) + ); + this.strokerMinSlider = this.percentSlider(this.strokerMinDistancePercent, value -> { + this.strokerMinDistancePercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetStrokerMinButton = this.resetButton(() -> { + this.strokerMinDistancePercent = this.defaults.getIntifaceStrokerMinDistancePercent(); + this.strokerMinSlider.setFloatValue(this.strokerMinDistancePercent); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_stroker_min_distance"), + this.strokerMinSlider, + this.resetStrokerMinButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stroker_min_distance") + ) + ); + this.strokerRegularSlider = this.durationSlider(this.strokerRegularMoveDurationMs, value -> { + this.strokerRegularMoveDurationMs = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetStrokerRegularButton = this.resetButton(() -> { + this.strokerRegularMoveDurationMs = this.defaults.getIntifaceStrokerRegularMoveDurationMs(); + this.strokerRegularSlider.setFloatValue(this.strokerRegularMoveDurationMs); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_stroker_regular_duration"), + this.strokerRegularSlider, + this.resetStrokerRegularButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stroker_regular_duration") + ) + ); + this.strokerPeakSlider = this.durationSlider(this.strokerPeakMoveDurationMs, value -> { + this.strokerPeakMoveDurationMs = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetStrokerPeakButton = this.resetButton(() -> { + this.strokerPeakMoveDurationMs = this.defaults.getIntifaceStrokerPeakMoveDurationMs(); + this.strokerPeakSlider.setFloatValue(this.strokerPeakMoveDurationMs); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_stroker_peak_duration"), + this.strokerPeakSlider, + this.resetStrokerPeakButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stroker_peak_duration") + ) + ); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.saveValues(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void saveValues() { + this.config.setIntifaceOscillatorRegularSpeedPercent(this.oscillatorRegularSpeedPercent); + this.config.setIntifaceOscillatorPeakSpeedPercent(this.oscillatorPeakSpeedPercent); + this.config.setIntifaceStrokerDistancesPercent(this.strokerMinDistancePercent, this.strokerMaxDistancePercent); + this.config.setIntifaceStrokerRegularMoveDurationMs(this.strokerRegularMoveDurationMs); + this.config.setIntifaceStrokerPeakMoveDurationMs(this.strokerPeakMoveDurationMs); + this.config.save(); + this.loadFromConfig(); + } + + private void loadFromConfig() { + this.oscillatorRegularSpeedPercent = this.config.getIntifaceOscillatorRegularSpeedPercent(); + this.oscillatorPeakSpeedPercent = this.config.getIntifaceOscillatorPeakSpeedPercent(); + this.strokerMinDistancePercent = this.config.getIntifaceStrokerMinDistancePercent(); + this.strokerMaxDistancePercent = this.config.getIntifaceStrokerMaxDistancePercent(); + this.strokerRegularMoveDurationMs = this.config.getIntifaceStrokerRegularMoveDurationMs(); + this.strokerPeakMoveDurationMs = this.config.getIntifaceStrokerPeakMoveDurationMs(); + } + + private ButtonWidget resetButton(Runnable action) { + ButtonWidget button = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), ignored -> { + if (action != null) { + action.run(); + } + + this.updateResetButtons(); + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(button, "config.needsofnature.tooltip.reset"); + return button; + } + + private FloatValueSliderWidget percentSlider(int initial, DoubleConsumer listener) { + return new FloatValueSliderWidget(0, 0, 130, 20, initial, 0.0F, 100.0F, 1.0F, listener, value -> String.format(Locale.ROOT, "%d%%", Math.round(value))); + } + + private FloatValueSliderWidget durationSlider(int initial, DoubleConsumer listener) { + return new FloatValueSliderWidget(0, 0, 130, 20, initial, 0.0F, 5000.0F, 10.0F, listener, value -> String.format(Locale.ROOT, "%dms", Math.round(value))); + } + + private void updateResetButtons() { + if (this.resetOscillatorRegularButton != null) { + this.resetOscillatorRegularButton.active = this.oscillatorRegularSpeedPercent != this.defaults.getIntifaceOscillatorRegularSpeedPercent(); + } + + if (this.resetOscillatorPeakButton != null) { + this.resetOscillatorPeakButton.active = this.oscillatorPeakSpeedPercent != this.defaults.getIntifaceOscillatorPeakSpeedPercent(); + } + + if (this.resetStrokerMinButton != null) { + this.resetStrokerMinButton.active = this.strokerMinDistancePercent != this.defaults.getIntifaceStrokerMinDistancePercent(); + } + + if (this.resetStrokerMaxButton != null) { + this.resetStrokerMaxButton.active = this.strokerMaxDistancePercent != this.defaults.getIntifaceStrokerMaxDistancePercent(); + } + + if (this.resetStrokerRegularButton != null) { + this.resetStrokerRegularButton.active = this.strokerRegularMoveDurationMs != this.defaults.getIntifaceStrokerRegularMoveDurationMs(); + } + + if (this.resetStrokerPeakButton != null) { + this.resetStrokerPeakButton.active = this.strokerPeakMoveDurationMs != this.defaults.getIntifaceStrokerPeakMoveDurationMs(); + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntifaceTuningScreen.java b/src/main/java/com/nonid/integration/NonModMenuIntifaceTuningScreen.java new file mode 100644 index 0000000..6dc8e84 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntifaceTuningScreen.java @@ -0,0 +1,111 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import java.util.Locale; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; + +final class NonModMenuIntifaceTuningScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int latencyCompensationMs; + private FloatValueSliderWidget latencyCompensationSlider; + private ButtonWidget resetLatencyCompensationButton; + + NonModMenuIntifaceTuningScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.intiface_toy_title")); + this.parent = parent; + this.config = config; + this.loadFromConfig(); + } + + protected void init() { + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_timing"))); + this.latencyCompensationSlider = new FloatValueSliderWidget(0, 0, 130, 20, this.latencyCompensationMs, 0.0F, 1000.0F, 10.0F, value -> { + this.latencyCompensationMs = (int)Math.round(value); + this.updateResetButton(); + }, value -> String.format(Locale.ROOT, "%dms early", Math.round(value))); + this.resetLatencyCompensationButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), ignored -> { + this.latencyCompensationMs = this.defaults.getIntifaceLatencyCompensationMs(); + this.latencyCompensationSlider.setFloatValue(this.latencyCompensationMs); + this.updateResetButton(); + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(this.resetLatencyCompensationButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_latency_compensation"), + this.latencyCompensationSlider, + this.resetLatencyCompensationButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_latency_compensation") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_toy_settings"))); + ButtonWidget vibratorButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.intiface_vibrator_settings"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceVibratorScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(vibratorButton, "config.needsofnature.tooltip.intiface_vibrator_settings"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, vibratorButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_vibrator_settings") + ) + ); + ButtonWidget strokerOscillatorButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.intiface_stroker_oscillator_settings"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceStrokerOscillatorScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(strokerOscillatorButton, "config.needsofnature.tooltip.intiface_stroker_oscillator_settings"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, strokerOscillatorButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_stroker_oscillator_settings") + ) + ); + int centerX = this.width / 2; + this.updateResetButton(); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButton(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.config.setIntifaceLatencyCompensationMs(this.latencyCompensationMs); + this.config.save(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void loadFromConfig() { + this.latencyCompensationMs = this.config.getIntifaceLatencyCompensationMs(); + } + + private void updateResetButton() { + if (this.resetLatencyCompensationButton != null) { + this.resetLatencyCompensationButton.active = this.latencyCompensationMs != this.defaults.getIntifaceLatencyCompensationMs(); + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuIntifaceVibratorScreen.java b/src/main/java/com/nonid/integration/NonModMenuIntifaceVibratorScreen.java new file mode 100644 index 0000000..cfe2f1b --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuIntifaceVibratorScreen.java @@ -0,0 +1,290 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import java.util.Locale; +import java.util.function.DoubleConsumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +final class NonModMenuIntifaceVibratorScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private int reactiveStrengthPercent; + private int reactiveDurationMs; + private int peakStrengthPercent; + private int peakDurationMs; + private int cooldownMs; + private int animationBaselineStrengthPercent; + private final int[] energizedBasePercent = new int[3]; + private final int[] energizedPulsePercent = new int[3]; + private FloatValueSliderWidget reactiveStrengthSlider; + private TextFieldWidget reactiveDurationField; + private ButtonWidget resetReactiveButton; + private FloatValueSliderWidget peakStrengthSlider; + private TextFieldWidget peakDurationField; + private ButtonWidget resetPeakButton; + private TextFieldWidget cooldownField; + private ButtonWidget resetCooldownButton; + private FloatValueSliderWidget animationBaselineSlider; + private ButtonWidget resetAnimationBaselineButton; + private final FloatValueSliderWidget[] energizedBaseSliders = new FloatValueSliderWidget[3]; + private final FloatValueSliderWidget[] energizedPulseSliders = new FloatValueSliderWidget[3]; + private final ButtonWidget[] resetEnergizedButtons = new ButtonWidget[3]; + + NonModMenuIntifaceVibratorScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.intiface_vibrator_title")); + this.parent = parent; + this.config = config; + this.loadFromConfig(); + } + + protected void init() { + if (this.reactiveDurationField != null) { + this.reactiveDurationMs = this.parseField(this.reactiveDurationField, this.reactiveDurationMs); + this.peakDurationMs = this.parseField(this.peakDurationField, this.peakDurationMs); + this.cooldownMs = this.parseField(this.cooldownField, this.cooldownMs); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + settingsList.addEntryRow( + SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_reactiveimpact")) + ); + this.reactiveStrengthSlider = this.percentSlider(this.reactiveStrengthPercent, value -> { + this.reactiveStrengthPercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.reactiveDurationField = this.newNumberField(70, this.reactiveDurationMs); + this.reactiveDurationField.setChangedListener(ignored -> this.updateResetButtons()); + this.resetReactiveButton = this.resetButton(() -> { + this.reactiveStrengthPercent = this.defaults.getIntifaceReactiveImpactStrengthPercent(); + this.reactiveDurationMs = this.defaults.getIntifaceReactiveImpactDurationMs(); + this.reactiveStrengthSlider.setFloatValue(this.reactiveStrengthPercent); + this.reactiveDurationField.setText(String.valueOf(this.reactiveDurationMs)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_reactiveimpact"), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_reactiveimpact"), + this.reactiveStrengthSlider, + this.reactiveDurationField, + this.resetReactiveButton + ) + ); + this.peakStrengthSlider = this.percentSlider(this.peakStrengthPercent, value -> { + this.peakStrengthPercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.peakDurationField = this.newNumberField(70, this.peakDurationMs); + this.peakDurationField.setChangedListener(ignored -> this.updateResetButtons()); + this.resetPeakButton = this.resetButton(() -> { + this.peakStrengthPercent = this.defaults.getIntifacePeakReactiveImpactStrengthPercent(); + this.peakDurationMs = this.defaults.getIntifacePeakReactiveImpactDurationMs(); + this.peakStrengthSlider.setFloatValue(this.peakStrengthPercent); + this.peakDurationField.setText(String.valueOf(this.peakDurationMs)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_peak_reactiveimpact"), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_peak_reactiveimpact"), + this.peakStrengthSlider, + this.peakDurationField, + this.resetPeakButton + ) + ); + this.cooldownField = this.newNumberField(70, this.cooldownMs); + this.cooldownField.setChangedListener(ignored -> this.updateResetButtons()); + this.resetCooldownButton = this.resetButton(() -> { + this.cooldownMs = this.defaults.getIntifaceCooldownMs(); + this.cooldownField.setText(String.valueOf(this.cooldownMs)); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_cooldown"), + this.cooldownField, + this.resetCooldownButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_cooldown") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_baseline"))); + this.animationBaselineSlider = this.percentSlider(this.animationBaselineStrengthPercent, value -> { + this.animationBaselineStrengthPercent = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetAnimationBaselineButton = this.resetButton(() -> { + this.animationBaselineStrengthPercent = this.defaults.getIntifaceAnimationBaselineStrengthPercent(); + this.animationBaselineSlider.setFloatValue(this.animationBaselineStrengthPercent); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_animation_baseline"), + this.animationBaselineSlider, + this.resetAnimationBaselineButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_animation_baseline") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.intiface_energized"))); + + for (int level = 1; level <= 3; level++) { + int index = level - 1; + int rowLevel = level; + this.energizedBaseSliders[index] = this.percentSlider(this.energizedBasePercent[index], value -> { + this.energizedBasePercent[rowLevel - 1] = (int)Math.round(value); + this.updateResetButtons(); + }); + this.energizedPulseSliders[index] = this.percentSlider(this.energizedPulsePercent[index], value -> { + this.energizedPulsePercent[rowLevel - 1] = (int)Math.round(value); + this.updateResetButtons(); + }); + this.resetEnergizedButtons[index] = this.resetButton(() -> { + this.energizedBasePercent[rowLevel - 1] = this.defaults.getIntifaceEnergizedBasePercent(rowLevel); + this.energizedPulsePercent[rowLevel - 1] = this.defaults.getIntifaceEnergizedPulsePercent(rowLevel); + this.energizedBaseSliders[rowLevel - 1].setFloatValue(this.energizedBasePercent[rowLevel - 1]); + this.energizedPulseSliders[rowLevel - 1].setFloatValue(this.energizedPulsePercent[rowLevel - 1]); + }); + settingsList.addEntryRow( + SettingsList.RowEntry.groupedField( + this.textRenderer, + Text.translatable("config.needsofnature.intiface_energized_level", new Object[]{level}), + NonModMenuScreens.tooltip("config.needsofnature.tooltip.intiface_energized"), + this.energizedBaseSliders[index], + this.energizedPulseSliders[index], + this.resetEnergizedButtons[index] + ) + ); + } + + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.saveValues(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void saveValues() { + this.reactiveDurationMs = this.parseField(this.reactiveDurationField, this.reactiveDurationMs); + this.peakDurationMs = this.parseField(this.peakDurationField, this.peakDurationMs); + this.cooldownMs = this.parseField(this.cooldownField, this.cooldownMs); + this.config.setIntifaceReactiveImpactStrengthPercent(this.reactiveStrengthPercent); + this.config.setIntifaceReactiveImpactDurationMs(this.reactiveDurationMs); + this.config.setIntifacePeakReactiveImpactStrengthPercent(this.peakStrengthPercent); + this.config.setIntifacePeakReactiveImpactDurationMs(this.peakDurationMs); + this.config.setIntifaceCooldownMs(this.cooldownMs); + this.config.setIntifaceAnimationBaselineStrengthPercent(this.animationBaselineStrengthPercent); + + for (int level = 1; level <= 3; level++) { + this.config.setIntifaceEnergizedBasePercent(level, this.energizedBasePercent[level - 1]); + this.config.setIntifaceEnergizedPulsePercent(level, this.energizedPulsePercent[level - 1]); + } + + this.config.save(); + this.loadFromConfig(); + } + + private void loadFromConfig() { + this.reactiveStrengthPercent = this.config.getIntifaceReactiveImpactStrengthPercent(); + this.reactiveDurationMs = this.config.getIntifaceReactiveImpactDurationMs(); + this.peakStrengthPercent = this.config.getIntifacePeakReactiveImpactStrengthPercent(); + this.peakDurationMs = this.config.getIntifacePeakReactiveImpactDurationMs(); + this.cooldownMs = this.config.getIntifaceCooldownMs(); + this.animationBaselineStrengthPercent = this.config.getIntifaceAnimationBaselineStrengthPercent(); + + for (int level = 1; level <= 3; level++) { + this.energizedBasePercent[level - 1] = this.config.getIntifaceEnergizedBasePercent(level); + this.energizedPulsePercent[level - 1] = this.config.getIntifaceEnergizedPulsePercent(level); + } + } + + private ButtonWidget resetButton(Runnable action) { + ButtonWidget button = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), ignored -> { + if (action != null) { + action.run(); + } + + this.updateResetButtons(); + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(button, "config.needsofnature.tooltip.reset"); + return button; + } + + private FloatValueSliderWidget percentSlider(int initial, DoubleConsumer listener) { + return new FloatValueSliderWidget(0, 0, 130, 20, initial, 0.0F, 100.0F, 1.0F, listener, value -> String.format(Locale.ROOT, "%d%%", Math.round(value))); + } + + private TextFieldWidget newNumberField(int width, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, width, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + if (field == null) { + return fallback; + } + + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private void updateResetButtons() { + if (this.resetReactiveButton != null) { + this.resetReactiveButton.active = this.reactiveStrengthPercent != this.defaults.getIntifaceReactiveImpactStrengthPercent() + || this.parseField(this.reactiveDurationField, this.reactiveDurationMs) != this.defaults.getIntifaceReactiveImpactDurationMs(); + } + + if (this.resetPeakButton != null) { + this.resetPeakButton.active = this.peakStrengthPercent != this.defaults.getIntifacePeakReactiveImpactStrengthPercent() + || this.parseField(this.peakDurationField, this.peakDurationMs) != this.defaults.getIntifacePeakReactiveImpactDurationMs(); + } + + if (this.resetCooldownButton != null) { + this.resetCooldownButton.active = this.parseField(this.cooldownField, this.cooldownMs) != this.defaults.getIntifaceCooldownMs(); + } + + if (this.resetAnimationBaselineButton != null) { + this.resetAnimationBaselineButton.active = this.animationBaselineStrengthPercent != this.defaults.getIntifaceAnimationBaselineStrengthPercent(); + } + + for (int level = 1; level <= 3; level++) { + ButtonWidget reset = this.resetEnergizedButtons[level - 1]; + if (reset != null) { + reset.active = this.energizedBasePercent[level - 1] != this.defaults.getIntifaceEnergizedBasePercent(level) + || this.energizedPulsePercent[level - 1] != this.defaults.getIntifaceEnergizedPulsePercent(level); + } + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuPlayerScreens.java b/src/main/java/com/nonid/integration/NonModMenuPlayerScreens.java new file mode 100644 index 0000000..77151c1 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuPlayerScreens.java @@ -0,0 +1,923 @@ +package com.nonid.integration; + +import com.nonid.GenderHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NeedsOfNatureClient; +import com.nonid.NonConfig; +import com.nonid.client.NonDestroyedSkinClient; +import com.nonid.client.NonWildfireGenderSync; +import java.io.IOException; +import java.util.Locale; +import java.util.function.DoubleFunction; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; + +final class NonModMenuPlayerScreens { + private NonModMenuPlayerScreens() { + } + + static class DestroyedSkinTintPickerScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final String initialHex; + private Identifier skinTexture; + private NativeImage skinImage; + private int selectedRgb = 13803641; + private int skinX; + private int skinY; + private int skinSize; + private int selectedPixelX = -1; + private int selectedPixelY = -1; + private TextFieldWidget tintField; + + protected DestroyedSkinTintPickerScreen(Screen parent, NonConfig config, String initialHex) { + super(Text.translatable("config.needsofnature.destroyed_skin_tint.pick_title")); + this.parent = parent; + this.config = config; + this.initialHex = initialHex == null ? "" : initialHex; + this.selectedRgb = parseHexOrDefault(this.initialHex, this.selectedRgb); + } + + protected void init() { + this.loadSkinImage(); + this.skinSize = Math.min(320, Math.max(128, Math.min(this.width - 40, this.height - 136))); + this.skinX = (this.width - this.skinSize) / 2; + this.skinY = 40; + int centerX = this.width / 2; + this.tintField = new TextFieldWidget(this.textRenderer, centerX - 15, this.height - 62, 80, 20, Text.literal("")); + this.tintField.setMaxLength(7); + this.tintField.setText(formatRgb(this.selectedRgb)); + this.tintField.setChangedListener(ignored -> this.updateSelectedRgbFromField()); + this.addDrawableChild(this.tintField); + ButtonWidget useButton = ButtonWidget.builder(Text.translatable("config.needsofnature.destroyed_skin_tint.use"), button -> { + String pickedHex = this.sanitizeHexInput(this.tintField, formatRgb(this.selectedRgb)); + this.selectedRgb = parseHexOrDefault(pickedHex, this.selectedRgb); + this.config.setDestroyedSkinTintHex(pickedHex); + this.config.save(); + NonDestroyedSkinClient.uploadLocalSkinIfPresent(); + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX - 155, this.height - 28, 100, 20).build(); + this.addDrawableChild(useButton); + ButtonWidget autoButton = ButtonWidget.builder(Text.translatable("config.needsofnature.destroyed_skin_tint.auto"), button -> { + this.setSelectedRgb(NonDestroyedSkinClient.detectSkinTintFromCurrentPlayer(this.selectedRgb)); + this.selectedPixelX = -1; + this.selectedPixelY = -1; + }).dimensions(centerX - 50, this.height - 28, 100, 20).build(); + NonModMenuScreens.setTooltip(autoButton, "config.needsofnature.tooltip.destroyed_skin_tint.auto"); + this.addDrawableChild(autoButton); + ButtonWidget cancelButton = ButtonWidget.builder(Text.translatable("gui.cancel"), button -> MinecraftClient.getInstance().setScreen(this.parent)) + .dimensions(centerX + 55, this.height - 28, 100, 20) + .build(); + this.addDrawableChild(cancelButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, 16777215); + if (this.skinTexture != null) { + context.fill(this.skinX, this.skinY, this.skinX + this.skinSize, this.skinY + this.skinSize, 0xFF000000 | this.selectedRgb); + context.drawTexture( + RenderPipelines.GUI_TEXTURED, this.skinTexture, this.skinX, this.skinY, 0.0F, 0.0F, this.skinSize, this.skinSize, 64, 64, 64, 64 + ); + context.fill(this.skinX - 1, this.skinY - 1, this.skinX + this.skinSize + 1, this.skinY, -1); + context.fill(this.skinX - 1, this.skinY + this.skinSize, this.skinX + this.skinSize + 1, this.skinY + this.skinSize + 1, -1); + context.fill(this.skinX - 1, this.skinY - 1, this.skinX, this.skinY + this.skinSize + 1, -1); + context.fill(this.skinX + this.skinSize, this.skinY - 1, this.skinX + this.skinSize + 1, this.skinY + this.skinSize + 1, -1); + } else { + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_tint.no_skin"), + this.width / 2, + this.skinY + this.skinSize / 2, + 16744576 + ); + } + + if (this.selectedPixelX >= 0 && this.selectedPixelY >= 0) { + int px = this.skinX + this.selectedPixelX * this.skinSize / 64; + int py = this.skinY + this.selectedPixelY * this.skinSize / 64; + int cell = Math.max(1, this.skinSize / 64); + context.fill(px - 1, py - 1, px + cell + 1, py, -256); + context.fill(px - 1, py + cell, px + cell + 1, py + cell + 1, -256); + context.fill(px - 1, py - 1, px, py + cell + 1, -256); + context.fill(px + cell, py - 1, px + cell + 1, py + cell + 1, -256); + } + + int previewY = this.height - 58; + int previewX = this.width / 2 - 78; + context.fill(previewX, previewY, previewX + 24, previewY + 24, 0xFF000000 | this.selectedRgb); + context.fill(previewX - 1, previewY - 1, previewX + 25, previewY, -1); + context.fill(previewX - 1, previewY + 24, previewX + 25, previewY + 25, -1); + context.fill(previewX - 1, previewY - 1, previewX, previewY + 25, -1); + context.fill(previewX + 24, previewY - 1, previewX + 25, previewY + 25, -1); + context.drawTextWithShadow(this.textRenderer, Text.translatable("config.needsofnature.destroyed_skin_tint"), previewX + 34, previewY + 8, 16777215); + } + + public boolean mouseClicked(Click click, boolean doubled) { + return this.tryPickColor(click.x(), click.y()) ? true : super.mouseClicked(click, doubled); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + public void removed() { + if (this.skinImage != null) { + this.skinImage.close(); + this.skinImage = null; + } + } + + private void loadSkinImage() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + this.skinTexture = client.player.getSkin().body().texturePath(); + + try { + this.skinImage = NonDestroyedSkinClient.readTextureImage(client, this.skinTexture); + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.warn("[NoN] Failed to read player skin for tint picker.", e); + this.skinImage = null; + } + } + } + + private boolean tryPickColor(double mouseX, double mouseY) { + if (this.skinImage == null) { + return false; + } + + if (!(mouseX < this.skinX) && !(mouseX >= this.skinX + this.skinSize) && !(mouseY < this.skinY) && !(mouseY >= this.skinY + this.skinSize)) { + int pixelX = Math.max(0, Math.min(63, (int)((mouseX - this.skinX) * 64.0 / this.skinSize))); + int pixelY = Math.max(0, Math.min(63, (int)((mouseY - this.skinY) * 64.0 / this.skinSize))); + if (pixelX < this.skinImage.getWidth() && pixelY < this.skinImage.getHeight()) { + int argb = this.skinImage.getColorArgb(pixelX, pixelY); + if ((argb >>> 24 & 0xFF) < 32) { + return true; + } + + this.setSelectedRgb(argb & 16777215); + this.selectedPixelX = pixelX; + this.selectedPixelY = pixelY; + return true; + } else { + return false; + } + } else { + return false; + } + } + + private void setSelectedRgb(int rgb) { + this.selectedRgb = rgb & 16777215; + if (this.tintField != null) { + this.tintField.setText(formatRgb(this.selectedRgb)); + } + } + + private void updateSelectedRgbFromField() { + this.selectedRgb = parseHexOrDefault(this.sanitizeHexInput(this.tintField, formatRgb(this.selectedRgb)), this.selectedRgb); + } + + private String sanitizeHexInput(TextFieldWidget field, String fallback) { + if (field == null) { + return fallback == null ? "" : fallback; + } + + String value = field.getText() == null ? "" : field.getText().trim(); + if (value.startsWith("#")) { + value = value.substring(1); + } + + value = value.toUpperCase(Locale.ROOT); + if (value.isEmpty()) { + return ""; + } else { + return value.matches("[0-9A-F]{6}") ? value : (fallback == null ? "" : fallback); + } + } + + private static int parseHexOrDefault(String hex, int fallback) { + if (hex == null) { + return fallback; + } + + String value = hex.trim(); + if (value.startsWith("#")) { + value = value.substring(1); + } + + if (!value.matches("[0-9a-fA-F]{6}")) { + return fallback; + } + + try { + return Integer.parseUnsignedInt(value, 16) & 16777215; + } catch (NumberFormatException e) { + return fallback; + } + } + + private static String formatRgb(int rgb) { + return String.format(Locale.ROOT, "%06X", rgb & 16777215); + } + } + + static class FemaleGenderDestroyedOverridesScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean overridesEnabled; + private ButtonWidget overridesButton; + private ButtonWidget resetOverridesButton; + private NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.ProfileFields lowFields; + private NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.ProfileFields highFields; + + protected FemaleGenderDestroyedOverridesScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.destroyed_skin_female_gender_profiles_title")); + this.parent = parent; + this.config = config; + this.overridesEnabled = config.isDestroyedSkinFemaleGenderOverridesEnabled(); + } + + protected void init() { + int listTop = 156; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + this.overridesButton = ButtonWidget.builder(toggleText(this.overridesEnabled), button -> { + this.overridesEnabled = !this.overridesEnabled; + this.updateOverrideControls(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.overridesButton, "config.needsofnature.tooltip.destroyed_skin_female_gender_overrides"); + this.resetOverridesButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.overridesEnabled = this.defaults.isDestroyedSkinFemaleGenderOverridesEnabled(); + this.updateOverrideControls(); + }).dimensions(0, 0, 20, 20).build(); + NonModMenuScreens.setTooltip(this.resetOverridesButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.destroyed_skin_female_gender_overrides"), + this.overridesButton, + this.resetOverridesButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.destroyed_skin_female_gender_overrides") + ) + ); + ButtonWidget breastTextureEditorButton = ButtonWidget.builder(Text.translatable("wildfire_gender.uv_editor"), button -> { + this.saveValues(); + NonWildfireGenderSync.openBreastTextureEditor(this); + }).dimensions(0, 0, 220, 20).build(); + breastTextureEditorButton.active = NonWildfireGenderSync.canOpenBreastTextureEditor(); + NonModMenuScreens.setTooltip(breastTextureEditorButton, "config.needsofnature.tooltip.female_gender.breast_texture_editor"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, breastTextureEditorButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.female_gender.breast_texture_editor") + ) + ); + this.lowFields = new NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.ProfileFields(this.config.getFemaleGenderDestroyedLow()); + this.highFields = new NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.ProfileFields(this.config.getFemaleGenderDestroyedHigh()); + this.lowFields.addRows(settingsList, this.textRenderer, Text.translatable("config.needsofnature.destroyed_skin_female_gender_profile.low")); + this.highFields.addRows(settingsList, this.textRenderer, Text.translatable("config.needsofnature.destroyed_skin_female_gender_profile.high")); + this.updateOverrideControls(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> { + this.saveValues(); + MinecraftClient.getInstance().setScreen(this.parent); + }).dimensions(centerX - 100, this.height - 28, 200, 20).build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + this.renderPreviewPanel(context, mouseX, mouseY); + } + + public void close() { + this.saveValues(); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void saveValues() { + this.config.setDestroyedSkinFemaleGenderOverridesEnabled(this.overridesEnabled); + if (this.lowFields != null) { + this.config.setFemaleGenderDestroyedLow(this.lowFields.toProfile(this.config.getFemaleGenderDestroyedLow())); + } + + if (this.highFields != null) { + this.config.setFemaleGenderDestroyedHigh(this.highFields.toProfile(this.config.getFemaleGenderDestroyedHigh())); + } + + this.config.save(); + NonWildfireGenderSync.syncDestroyedSkinOverrides(this.config); + } + + private void updateOverrideControls() { + if (this.overridesButton != null) { + this.overridesButton.setMessage(toggleText(this.overridesEnabled)); + } + + if (this.resetOverridesButton != null) { + this.resetOverridesButton.active = this.overridesEnabled != this.defaults.isDestroyedSkinFemaleGenderOverridesEnabled(); + } + + if (this.lowFields != null) { + this.lowFields.setActive(this.overridesEnabled); + } + + if (this.highFields != null) { + this.highFields.setActive(this.overridesEnabled); + } + } + + private static Text toggleText(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private void renderPreviewPanel(DrawContext context, int mouseX, int mouseY) { + int panelTop = 34; + int panelBottom = 148; + int panelWidth = 160; + int gap = 14; + int leftX = this.width / 2 - panelWidth - gap / 2; + int rightX = this.width / 2 + gap / 2; + this.renderSinglePreview( + context, + mouseX, + mouseY, + leftX, + panelTop, + panelWidth, + panelBottom - panelTop, + Text.translatable("config.needsofnature.destroyed_skin_female_gender_profile.low"), + this.lowFields == null ? this.config.getFemaleGenderDestroyedLow() : this.lowFields.toProfile(this.config.getFemaleGenderDestroyedLow()) + ); + this.renderSinglePreview( + context, + mouseX, + mouseY, + rightX, + panelTop, + panelWidth, + panelBottom - panelTop, + Text.translatable("config.needsofnature.destroyed_skin_female_gender_profile.high"), + this.highFields == null ? this.config.getFemaleGenderDestroyedHigh() : this.highFields.toProfile(this.config.getFemaleGenderDestroyedHigh()) + ); + } + + private void renderSinglePreview( + DrawContext context, int mouseX, int mouseY, int x, int y, int w, int h, Text label, NonConfig.FemaleGenderDestroyedProfile profile + ) { + context.fill(x, y, x + w, y + h, 1711276032); + context.fill(x, y, x + w, y + 1, -1996488705); + context.fill(x, y + h - 1, x + w, y + h, -2013265920); + context.fill(x, y, x + 1, y + h, -1996488705); + context.fill(x + w - 1, y, x + w, y + h, -2013265920); + context.drawCenteredTextWithShadow(this.textRenderer, label, x + w / 2, y + 6, -2039584); + boolean rendered = NonWildfireGenderSync.renderBreastPreview(this, context, profile, x + w / 2, y + h - 12, mouseX, mouseY); + if (!rendered) { + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("config.needsofnature.female_gender.preview_unavailable"), x + w / 2, y + h / 2, -32640 + ); + } + } + + private final class ProfileFields { + private final FloatValueSliderWidget breastSize; + private final FloatValueSliderWidget separation; + private final FloatValueSliderWidget height; + private final FloatValueSliderWidget depth; + private final FloatValueSliderWidget rotation; + private final ButtonWidget breastPhysics; + private final ButtonWidget dualPhysics; + private final FloatValueSliderWidget intensity; + private final FloatValueSliderWidget momentum; + private boolean breastPhysicsValue; + private boolean dualPhysicsValue; + + private ProfileFields(NonConfig.FemaleGenderDestroyedProfile profile) { + this.breastSize = this.newFloatSlider(profile.breastSize(), 0.0F, 1.6F, 0.008F, value -> this.formatPercent(value * 125.0)); + this.breastSize.setWarningAbove(0.8F); + this.separation = this.newFloatSlider(profile.separation(), -1.0F, 1.0F, 0.1F, value -> this.formatPlain(value * 10.0)); + this.height = this.newFloatSlider(profile.height(), -2.0F, 1.0F, 0.1F, value -> this.formatPlain(value * 10.0)); + this.depth = this.newFloatSlider(profile.depth(), -1.0F, 0.0F, 0.1F, value -> this.formatPlain(value * 10.0)); + this.rotation = this.newFloatSlider(profile.rotation(), 0.0F, 0.1F, 0.01F, value -> this.formatPlain(value * 100.0) + "°"); + this.breastPhysicsValue = profile.breastPhysics(); + this.dualPhysicsValue = profile.dualPhysics(); + this.breastPhysics = ButtonWidget.builder( + NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.toggleText(this.breastPhysicsValue), button -> { + this.breastPhysicsValue = !this.breastPhysicsValue; + button.setMessage(NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.toggleText(this.breastPhysicsValue)); + } + ) + .dimensions(0, 0, 70, 20) + .build(); + this.dualPhysics = ButtonWidget.builder(NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.toggleText(this.dualPhysicsValue), button -> { + this.dualPhysicsValue = !this.dualPhysicsValue; + button.setMessage(NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen.toggleText(this.dualPhysicsValue)); + }).dimensions(0, 0, 70, 20).build(); + this.intensity = this.newFloatSlider(profile.intensity(), 0.0F, 0.5F, 0.0033333334F, value -> this.formatPercent(value * 300.0)); + this.momentum = this.newFloatSlider(profile.momentum(), 0.25F, 1.0F, 0.0075F, value -> this.formatPercent((value - 0.25) / 0.75 * 100.0)); + } + + private void addRows(SettingsList settingsList, TextRenderer textRenderer, Text title) { + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(textRenderer, title)); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(textRenderer, Text.translatable("config.needsofnature.female_gender.customization"))); + this.addFloatRow( + settingsList, textRenderer, "breast_size", this.breastSize, "0% - 200%", "config.needsofnature.tooltip.female_gender.breast_size_extended" + ); + this.addFloatRow(settingsList, textRenderer, "separation", this.separation, "-10 - 10"); + this.addFloatRow(settingsList, textRenderer, "height", this.height, "-20 - 10"); + this.addFloatRow(settingsList, textRenderer, "depth", this.depth, "-10 - 0"); + this.addFloatRow(settingsList, textRenderer, "rotation", this.rotation, "0° - 10°"); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(textRenderer, Text.translatable("config.needsofnature.female_gender.physics"))); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + textRenderer, + Text.translatable("config.needsofnature.female_gender.breast_physics"), + this.breastPhysics, + null, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.female_gender.breast_physics") + ) + ); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + textRenderer, + Text.translatable("config.needsofnature.female_gender.dual_physics"), + this.dualPhysics, + null, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.female_gender.dual_physics") + ) + ); + this.addFloatRow(settingsList, textRenderer, "intensity", this.intensity, "0% - 150%"); + this.addFloatRow(settingsList, textRenderer, "momentum", this.momentum, "0% - 100%"); + } + + private void addFloatRow(SettingsList settingsList, TextRenderer textRenderer, String key, FloatValueSliderWidget field, String range) { + this.addFloatRow(settingsList, textRenderer, key, field, range, null); + } + + private void addFloatRow( + SettingsList settingsList, TextRenderer textRenderer, String key, FloatValueSliderWidget field, String range, String warningKey + ) { + MutableText tooltipText = Text.translatable("config.needsofnature.tooltip.female_gender.range", new Object[]{range}); + if (warningKey != null) { + tooltipText.append("\n").append(Text.translatable(warningKey).formatted(Formatting.YELLOW)); + } + + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + textRenderer, Text.translatable("config.needsofnature.female_gender." + key), field, null, NonModMenuScreens.tooltip(tooltipText) + ) + ); + } + + private void setActive(boolean active) { + this.breastSize.active = active; + this.separation.active = active; + this.height.active = active; + this.depth.active = active; + this.rotation.active = active; + this.breastPhysics.active = active; + this.dualPhysics.active = active; + this.intensity.active = active; + this.momentum.active = active; + } + + private NonConfig.FemaleGenderDestroyedProfile toProfile(NonConfig.FemaleGenderDestroyedProfile fallback) { + return new NonConfig.FemaleGenderDestroyedProfile( + this.breastSize.getFloatValue(), + this.separation.getFloatValue(), + this.height.getFloatValue(), + this.depth.getFloatValue(), + this.rotation.getFloatValue(), + this.breastPhysicsValue, + this.dualPhysicsValue, + this.intensity.getFloatValue(), + this.momentum.getFloatValue() + ); + } + + private FloatValueSliderWidget newFloatSlider(float initial, float min, float max, float step, DoubleFunction formatter) { + return new FloatValueSliderWidget(0, 0, 160, 20, initial, min, max, step, ignored -> {}, formatter); + } + + private String formatPercent(double value) { + return this.formatPlain(value) + "%"; + } + + private String formatPlain(double value) { + return String.format(Locale.ROOT, "%.0f", value); + } + } + } + + static class PlayerPreferencesScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private NonConfig.PlayerGenderSelection playerGenderSelection; + private boolean syncFemaleGenderModWithNonGender; + private boolean armorShedEffectEnabled; + private boolean autoSwitchThirdPersonOnAnimationStart; + private int actionSoundVolumePercent; + private int liquidPuddleDespawnSeconds; + private ButtonWidget playerGenderButton; + private ButtonWidget resetPlayerGenderButton; + private ButtonWidget syncFemaleGenderModWithNonGenderButton; + private ButtonWidget resetSyncFemaleGenderModWithNonGenderButton; + private ButtonWidget armorShedEffectButton; + private ButtonWidget resetArmorShedEffectButton; + private ButtonWidget autoSwitchThirdPersonButton; + private ButtonWidget resetAutoSwitchThirdPersonButton; + private PercentSliderWidget actionSoundVolumeSlider; + private ButtonWidget resetActionSoundVolumeButton; + private TextFieldWidget liquidPuddleDespawnField; + private ButtonWidget resetLiquidPuddleDespawnButton; + private boolean playerGenderEditable; + + protected PlayerPreferencesScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.player_preferences_title")); + this.parent = parent; + this.config = config; + this.playerGenderSelection = config.getPlayerGenderSelection(); + this.syncFemaleGenderModWithNonGender = config.isSyncFemaleGenderModWithNonGender(); + this.armorShedEffectEnabled = config.isArmorShedEffectEnabled(); + this.autoSwitchThirdPersonOnAnimationStart = config.autoSwitchThirdPersonOnAnimationStart(); + this.actionSoundVolumePercent = config.getActionSoundVolumePercent(); + this.liquidPuddleDespawnSeconds = config.getLiquidPuddleDespawnSeconds(); + } + + protected void init() { + if (this.liquidPuddleDespawnField != null) { + this.liquidPuddleDespawnSeconds = this.clampLiquidPuddleDespawnSeconds( + this.parseField(this.liquidPuddleDespawnField, this.liquidPuddleDespawnSeconds) + ); + } + + int listTop = 32; + int bottomArea = 40; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + this.playerGenderEditable = this.config.allowPlayerGenderChangeAnytime(); + if (!this.playerGenderEditable) { + this.playerGenderSelection = this.currentPlayerGenderSelection(this.playerGenderSelection); + } + + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.player_identity"))); + this.playerGenderButton = ButtonWidget.builder(this.playerGenderText(this.playerGenderSelection), button -> { + this.playerGenderSelection = this.playerGenderSelection.next(); + button.setMessage(this.playerGenderText(this.playerGenderSelection)); + this.updateResetButtons(); + }).dimensions(0, 0, 140, 20).build(); + this.playerGenderButton.active = this.playerGenderEditable; + NonModMenuScreens.setTooltip( + this.playerGenderButton, + this.playerGenderEditable ? "config.needsofnature.tooltip.player_gender" : "config.needsofnature.tooltip.player_gender.locked" + ); + this.resetPlayerGenderButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.playerGenderSelection = this.defaults.getPlayerGenderSelection(); + if (this.playerGenderButton != null) { + this.playerGenderButton.setMessage(this.playerGenderText(this.playerGenderSelection)); + } + + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetPlayerGenderButton, "config.needsofnature.tooltip.reset"); + this.resetPlayerGenderButton.active = this.playerGenderEditable; + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.player_gender"), + this.playerGenderButton, + this.resetPlayerGenderButton, + NonModMenuScreens.tooltip( + this.playerGenderEditable ? "config.needsofnature.tooltip.player_gender" : "config.needsofnature.tooltip.player_gender.locked" + ) + ) + ); + ButtonWidget playerProfilesButton = ButtonWidget.builder( + Text.translatable("gui.needsofnature.player_profiles.title"), + button -> MinecraftClient.getInstance().setScreen(new NonPlayerProfilesScreen(this)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(playerProfilesButton, "gui.needsofnature.player_profiles.tooltip"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, playerProfilesButton, NonModMenuScreens.tooltip("gui.needsofnature.player_profiles.tooltip") + ) + ); + if (NonWildfireGenderSync.isAvailable()) { + this.syncFemaleGenderModWithNonGenderButton = ButtonWidget.builder(this.toggleText(this.syncFemaleGenderModWithNonGender), button -> { + this.syncFemaleGenderModWithNonGender = !this.syncFemaleGenderModWithNonGender; + button.setMessage(this.toggleText(this.syncFemaleGenderModWithNonGender)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.syncFemaleGenderModWithNonGenderButton, "config.needsofnature.tooltip.sync_female_gender_mod"); + this.resetSyncFemaleGenderModWithNonGenderButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.syncFemaleGenderModWithNonGender = this.defaults.isSyncFemaleGenderModWithNonGender(); + if (this.syncFemaleGenderModWithNonGenderButton != null) { + this.syncFemaleGenderModWithNonGenderButton.setMessage(this.toggleText(this.syncFemaleGenderModWithNonGender)); + } + + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetSyncFemaleGenderModWithNonGenderButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.sync_female_gender_mod"), + this.syncFemaleGenderModWithNonGenderButton, + this.resetSyncFemaleGenderModWithNonGenderButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.sync_female_gender_mod") + ) + ); + ButtonWidget destroyedSkinFemaleGenderProfilesButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.destroyed_skin_female_gender_profiles"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuPlayerScreens.FemaleGenderDestroyedOverridesScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(destroyedSkinFemaleGenderProfilesButton, "config.needsofnature.tooltip.destroyed_skin_female_gender_profiles"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, + destroyedSkinFemaleGenderProfilesButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.destroyed_skin_female_gender_profiles") + ) + ); + } + + boolean destroyedSkinSystemEnabled = this.config.isDestroyedSkinSystemEnabled(); + boolean hasCustomDestroyedSkin = NonDestroyedSkinClient.hasLocalCustomDestroyedSkin(); + boolean hasDefaultDestroyedSkin = NonDestroyedSkinClient.hasCurrentDefaultDestroyedSkin(); + String destroyedSkinTintTooltip = !destroyedSkinSystemEnabled + ? "config.needsofnature.tooltip.destroyed_skin_tint.disabled_system" + : ( + hasCustomDestroyedSkin + ? "config.needsofnature.tooltip.destroyed_skin_tint.custom_skin" + : ( + hasDefaultDestroyedSkin + ? "config.needsofnature.tooltip.destroyed_skin_tint.default_skin" + : "config.needsofnature.tooltip.destroyed_skin_tint.pick" + ) + ); + ButtonWidget pickerButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.destroyed_skin_tint.pick"), + button -> MinecraftClient.getInstance() + .setScreen(new NonModMenuPlayerScreens.DestroyedSkinTintPickerScreen(this, this.config, this.config.getDestroyedSkinTintHex())) + ) + .dimensions(0, 0, 220, 20) + .build(); + pickerButton.active = destroyedSkinSystemEnabled && !hasCustomDestroyedSkin && !hasDefaultDestroyedSkin; + NonModMenuScreens.setTooltip(pickerButton, destroyedSkinTintTooltip); + settingsList.addEntryRow(SettingsList.RowEntry.buttonWithReset(this.textRenderer, pickerButton, NonModMenuScreens.tooltip(destroyedSkinTintTooltip))); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.local_visuals"))); + this.autoSwitchThirdPersonButton = ButtonWidget.builder(this.toggleText(this.autoSwitchThirdPersonOnAnimationStart), button -> { + this.autoSwitchThirdPersonOnAnimationStart = !this.autoSwitchThirdPersonOnAnimationStart; + button.setMessage(this.toggleText(this.autoSwitchThirdPersonOnAnimationStart)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.autoSwitchThirdPersonButton, "config.needsofnature.tooltip.animation.auto_switch_third_person"); + this.resetAutoSwitchThirdPersonButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.autoSwitchThirdPersonOnAnimationStart = this.defaults.autoSwitchThirdPersonOnAnimationStart(); + this.autoSwitchThirdPersonButton.setMessage(this.toggleText(this.autoSwitchThirdPersonOnAnimationStart)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetAutoSwitchThirdPersonButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.animation.auto_switch_third_person"), + this.autoSwitchThirdPersonButton, + this.resetAutoSwitchThirdPersonButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.animation.auto_switch_third_person") + ) + ); + this.armorShedEffectButton = ButtonWidget.builder(this.toggleText(this.armorShedEffectEnabled), button -> { + this.armorShedEffectEnabled = !this.armorShedEffectEnabled; + button.setMessage(this.toggleText(this.armorShedEffectEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.armorShedEffectButton, "config.needsofnature.tooltip.armor_shed_effect"); + this.resetArmorShedEffectButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.armorShedEffectEnabled = this.defaults.isArmorShedEffectEnabled(); + if (this.armorShedEffectButton != null) { + this.armorShedEffectButton.setMessage(this.toggleText(this.armorShedEffectEnabled)); + } + + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetArmorShedEffectButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.armor_shed_effect"), + this.armorShedEffectButton, + this.resetArmorShedEffectButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.armor_shed_effect") + ) + ); + this.actionSoundVolumeSlider = new PercentSliderWidget(0, 0, 180, 20, this.actionSoundVolumePercent, value -> { + this.actionSoundVolumePercent = value; + this.updateResetButtons(); + }); + NonModMenuScreens.setTooltip(this.actionSoundVolumeSlider, "config.needsofnature.tooltip.action_sound_volume"); + this.resetActionSoundVolumeButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.actionSoundVolumePercent = this.defaults.getActionSoundVolumePercent(); + if (this.actionSoundVolumeSlider != null) { + this.actionSoundVolumeSlider.setPercent(this.actionSoundVolumePercent); + } + + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetActionSoundVolumeButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.action_sound_volume"), + this.actionSoundVolumeSlider, + this.resetActionSoundVolumeButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.action_sound_volume") + ) + ); + this.liquidPuddleDespawnField = this.newNumberField(fieldWidth, this.liquidPuddleDespawnSeconds); + this.resetLiquidPuddleDespawnButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.liquidPuddleDespawnField.setText(String.valueOf(this.defaults.getLiquidPuddleDespawnSeconds())); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetLiquidPuddleDespawnButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.liquid_puddle_despawn_seconds"), + this.liquidPuddleDespawnField, + this.resetLiquidPuddleDespawnButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.liquid_puddle_despawn_seconds") + ) + ); + this.liquidPuddleDespawnField.setChangedListener(ignored -> this.updateResetButtons()); + ButtonWidget intifaceButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.intiface"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuIntifaceScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(intifaceButton, "config.needsofnature.tooltip.category.intiface"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, intifaceButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.category.intiface") + ) + ); + ButtonWidget interfaceButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.interface"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuHudConfigScreen.UiConfigScreen(this, this.config)) + ) + .dimensions(0, 0, 220, 20) + .build(); + NonModMenuScreens.setTooltip(interfaceButton, "config.needsofnature.tooltip.category.interface"); + settingsList.addEntryRow( + SettingsList.RowEntry.buttonWithReset( + this.textRenderer, interfaceButton, NonModMenuScreens.tooltip("config.needsofnature.tooltip.category.interface") + ) + ); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.liquidPuddleDespawnSeconds = this.clampLiquidPuddleDespawnSeconds(this.parseField(this.liquidPuddleDespawnField, this.liquidPuddleDespawnSeconds)); + if (this.playerGenderEditable) { + this.config.setPlayerGenderSelection(this.playerGenderSelection); + } + + this.config.setSyncFemaleGenderModWithNonGender(this.syncFemaleGenderModWithNonGender); + this.config.setArmorShedEffectEnabled(this.armorShedEffectEnabled); + this.config.setAutoSwitchThirdPersonOnAnimationStart(this.autoSwitchThirdPersonOnAnimationStart); + this.config.setActionSoundVolumePercent(this.actionSoundVolumePercent); + this.config.setLiquidPuddleDespawnSeconds(this.liquidPuddleDespawnSeconds); + this.config.save(); + if (this.playerGenderEditable) { + NeedsOfNatureClient.pushConfiguredPlayerGenderToServer(this.config); + } + + NonWildfireGenderSync.syncDestroyedSkinOverrides(this.config); + MinecraftClient.getInstance().setScreen(this.parent); + } + + private Text playerGenderText(NonConfig.PlayerGenderSelection selection) { + NonConfig.PlayerGenderSelection resolved = selection == null ? NonConfig.PlayerGenderSelection.FEMALE : selection; + + return switch (resolved) { + case MALE -> Text.translatable("config.needsofnature.player_gender.male"); + case FEMALE -> Text.translatable("config.needsofnature.player_gender.female"); + case BOTH -> Text.translatable("config.needsofnature.player_gender.both"); + }; + } + + private Text toggleText(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private NonConfig.PlayerGenderSelection currentPlayerGenderSelection(NonConfig.PlayerGenderSelection fallback) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player instanceof GenderHolder holder) { + int mask = holder.getGenderMask() & 3; + if (mask != 0) { + return NonConfig.PlayerGenderSelection.fromMask(mask, fallback); + } + } + + return fallback == null ? NonConfig.PlayerGenderSelection.FEMALE : fallback; + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + if (field == null) { + return fallback; + } + + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int clampLiquidPuddleDespawnSeconds(int v) { + return Math.max(1, Math.min(300, v)); + } + + private void updateResetButtons() { + if (this.resetPlayerGenderButton != null) { + this.resetPlayerGenderButton.active = this.playerGenderEditable && this.playerGenderSelection != this.defaults.getPlayerGenderSelection(); + } + + if (this.playerGenderButton != null) { + this.playerGenderButton.active = this.playerGenderEditable; + } + + if (this.resetSyncFemaleGenderModWithNonGenderButton != null) { + this.resetSyncFemaleGenderModWithNonGenderButton.active = this.syncFemaleGenderModWithNonGender + != this.defaults.isSyncFemaleGenderModWithNonGender(); + } + + if (this.resetArmorShedEffectButton != null) { + this.resetArmorShedEffectButton.active = this.armorShedEffectEnabled != this.defaults.isArmorShedEffectEnabled(); + } + + if (this.resetAutoSwitchThirdPersonButton != null) { + this.resetAutoSwitchThirdPersonButton.active = this.autoSwitchThirdPersonOnAnimationStart != this.defaults.autoSwitchThirdPersonOnAnimationStart(); + } + + if (this.resetActionSoundVolumeButton != null) { + this.resetActionSoundVolumeButton.active = this.actionSoundVolumePercent != this.defaults.getActionSoundVolumePercent(); + } + + if (this.resetLiquidPuddleDespawnButton != null) { + this.resetLiquidPuddleDespawnButton.active = this.parseField(this.liquidPuddleDespawnField, this.defaults.getLiquidPuddleDespawnSeconds()) + != this.defaults.getLiquidPuddleDespawnSeconds(); + } + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuScreens.java b/src/main/java/com/nonid/integration/NonModMenuScreens.java new file mode 100644 index 0000000..8371822 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuScreens.java @@ -0,0 +1,277 @@ +package com.nonid.integration; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.client.NonServerSettingsClient; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.Click; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.text.Text; + +final class NonModMenuScreens { + private static final Map SERVER_DRAFT_REVISIONS = Collections.synchronizedMap(new WeakHashMap<>()); + + private NonModMenuScreens() { + } + + static Tooltip tooltip(String key) { + return Tooltip.of(Text.translatable(key)); + } + + static Tooltip tooltip(Text text) { + return Tooltip.of(text); + } + + static void setTooltip(ClickableWidget widget, String key) { + if (widget != null) { + widget.setTooltip(tooltip(key)); + widget.setTooltipDelay(Duration.ofMillis(500L)); + } + } + + static void setTooltip(ClickableWidget widget, Tooltip tooltip) { + if (widget != null) { + widget.setTooltip(tooltip); + widget.setTooltipDelay(Duration.ofMillis(500L)); + } + } + + static boolean canEditServerGameplaySettings() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return true; + } else { + return client.world == null ? true : NonServerSettingsClient.hasConfigSnapshot() && (client.getServer() != null || NonServerSettingsClient.canEdit()); + } + } + + static boolean canEditLocalPackLoadOrder() { + MinecraftClient client = MinecraftClient.getInstance(); + return client == null || client.world == null || client.getServer() != null; + } + + static NonConfig serverConfigForScreen() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null) { + NonConfig draft = NonServerSettingsClient.createServerConfigDraft(); + SERVER_DRAFT_REVISIONS.put(draft, NonServerSettingsClient.configRevision()); + return draft; + } else { + return NeedsOfNature.getLocalConfig(); + } + } + + static void saveServerConfig(NonConfig config) { + if (config != null) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.world == null) { + config.save(); + } else if (canEditServerGameplaySettings()) { + long expectedRevision = SERVER_DRAFT_REVISIONS.getOrDefault(config, NonServerSettingsClient.configRevision()); + NonServerSettingsClient.sendConfigUpdate(config, expectedRevision); + SERVER_DRAFT_REVISIONS.put(config, expectedRevision + 1L); + } + } + } + + static Screen createConfigScreen(Screen parent) { + NonServerSettingsClient.requestRefresh(); + return new NonModMenuScreens.ConfigCategoryScreen(parent, NeedsOfNature.getLocalConfig()); + } + + private static class ConfigCategoryScreen extends Screen { + private final Screen parent; + private final NonConfig config; + + protected ConfigCategoryScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.categories_title")); + this.parent = parent; + this.config = config; + } + + protected void init() { + int centerX = this.width / 2; + int y = this.height / 2 - 36; + int gap = 24; + ButtonWidget togglesButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.toggles"), + button -> MinecraftClient.getInstance() + .setScreen(new NonModMenuSystemSettingsScreens.ToggleSettingsScreen(this, NonModMenuScreens.serverConfigForScreen())) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + NonModMenuScreens.setTooltip(togglesButton, "config.needsofnature.tooltip.category.toggles"); + this.addDrawableChild(togglesButton); + y += gap; + ButtonWidget playerButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.player_preferences"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuPlayerScreens.PlayerPreferencesScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + NonModMenuScreens.setTooltip(playerButton, "config.needsofnature.tooltip.category.player_preferences"); + this.addDrawableChild(playerButton); + y += gap; + ButtonWidget settingsButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.settings"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuScreens.SettingsCategoryScreen(this, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + NonModMenuScreens.setTooltip(settingsButton, "config.needsofnature.tooltip.category.settings"); + this.addDrawableChild(settingsButton); + ButtonWidget doneButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent) + ) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + } + + static final class FlatTextButton extends ClickableWidget { + private final NonModMenuScreens.FlatTextButton.FlatPressAction onPress; + + FlatTextButton(int width, Text text, NonModMenuScreens.FlatTextButton.FlatPressAction onPress) { + super(0, 0, width, 12, text); + this.onPress = onPress; + } + + protected void renderWidget(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + TextRenderer renderer = MinecraftClient.getInstance().textRenderer; + if (renderer != null) { + int rgb = this.active ? (this.isSelected() ? 16777215 : 13684944) : 8421504; + int alpha = Math.max(0, Math.min(255, (int)(this.getAlpha() * 255.0F))); + int color = alpha << 24 | rgb; + int textX = this.getX() + (this.getWidth() - renderer.getWidth(this.getMessage())) / 2; + int textY = this.getY() + (this.getHeight() - 8) / 2; + context.drawTextWithShadow(renderer, this.getMessage(), textX, textY, color); + } + } + + public void onClick(Click click, boolean doubled) { + super.onClick(click, doubled); + if (this.active && this.onPress != null) { + this.onPress.onPress(this); + } + } + + protected void appendClickableNarrations(NarrationMessageBuilder builder) { + this.appendDefaultNarrations(builder); + } + + @FunctionalInterface + interface FlatPressAction { + void onPress(NonModMenuScreens.FlatTextButton var1); + } + } + + private static class SettingsCategoryScreen extends Screen { + private final Screen parent; + private final NonConfig config; + + protected SettingsCategoryScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.settings_title")); + this.parent = parent; + this.config = config; + } + + protected void init() { + NonConfig serverConfig = NonModMenuScreens.serverConfigForScreen(); + int centerX = this.width / 2; + int y = this.height / 2 - 58; + int gap = 24; + ButtonWidget gameplayButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.gameplay"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuGameplayScreens.GameplayCategoryScreen(this, serverConfig)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + NonModMenuScreens.setTooltip(gameplayButton, "config.needsofnature.tooltip.category.gameplay"); + this.addDrawableChild(gameplayButton); + y += gap; + ButtonWidget vanillaOverridesButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.vanilla_overrides"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuSystemSettingsScreens.VanillaOverrideScreen(this, serverConfig)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + vanillaOverridesButton.active = serverConfig.isVanillaOverridesEnabled(); + NonModMenuScreens.setTooltip( + vanillaOverridesButton, + vanillaOverridesButton.active + ? "config.needsofnature.tooltip.category.vanilla_overrides" + : "config.needsofnature.tooltip.category.disabled_in_toggles" + ); + this.addDrawableChild(vanillaOverridesButton); + y += gap; + ButtonWidget liquidButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.liquid"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuSystemSettingsScreens.LiquidSettingsScreen(this, serverConfig)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + liquidButton.active = serverConfig.isLiquidTankEnabled(); + NonModMenuScreens.setTooltip( + liquidButton, liquidButton.active ? "config.needsofnature.tooltip.category.liquid" : "config.needsofnature.tooltip.category.disabled_in_toggles" + ); + this.addDrawableChild(liquidButton); + y += gap; + ButtonWidget pregnancyButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.pregnancy"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuSystemSettingsScreens.PregnancySettingsScreen(this, serverConfig)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + pregnancyButton.active = serverConfig.isPregnancyEnabled(); + NonModMenuScreens.setTooltip( + pregnancyButton, + pregnancyButton.active ? "config.needsofnature.tooltip.category.pregnancy" : "config.needsofnature.tooltip.category.disabled_in_toggles" + ); + this.addDrawableChild(pregnancyButton); + y += gap; + ButtonWidget debugButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.category.debug_advanced"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.DebugAdvancedScreen(this, serverConfig, this.config)) + ) + .dimensions(centerX - 100, y, 200, 20) + .build(); + NonModMenuScreens.setTooltip(debugButton, "config.needsofnature.tooltip.category.debug_advanced"); + this.addDrawableChild(debugButton); + ButtonWidget doneButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.done"), button -> MinecraftClient.getInstance().setScreen(this.parent) + ) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + } +} diff --git a/src/main/java/com/nonid/integration/NonModMenuSystemSettingsScreens.java b/src/main/java/com/nonid/integration/NonModMenuSystemSettingsScreens.java new file mode 100644 index 0000000..1976907 --- /dev/null +++ b/src/main/java/com/nonid/integration/NonModMenuSystemSettingsScreens.java @@ -0,0 +1,1394 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import java.util.Locale; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +final class NonModMenuSystemSettingsScreens { + private NonModMenuSystemSettingsScreens() { + } + + static class LiquidSettingsScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean serverConfigEditable; + private int liquidTankCapacityMl; + private double liquidDecay; + private double peakXpPerMl; + private double filledStageOneSpeed; + private double filledStageTwoSpeed; + private double filledStageThreeSpeed; + private double filledStageOneJump; + private double filledStageTwoJump; + private double filledStageThreeJump; + private TextFieldWidget liquidTankCapacityField; + private TextFieldWidget liquidDecayField; + private TextFieldWidget peakXpPerMlField; + private TextFieldWidget filledStageOneSpeedField; + private TextFieldWidget filledStageTwoSpeedField; + private TextFieldWidget filledStageThreeSpeedField; + private TextFieldWidget filledStageOneJumpField; + private TextFieldWidget filledStageTwoJumpField; + private TextFieldWidget filledStageThreeJumpField; + private ButtonWidget resetLiquidTankCapacityButton; + private ButtonWidget resetLiquidDecayButton; + private ButtonWidget resetPeakXpPerMlButton; + private ButtonWidget resetFilledStageOneSpeedButton; + private ButtonWidget resetFilledStageTwoSpeedButton; + private ButtonWidget resetFilledStageThreeSpeedButton; + private ButtonWidget resetFilledStageOneJumpButton; + private ButtonWidget resetFilledStageTwoJumpButton; + private ButtonWidget resetFilledStageThreeJumpButton; + + protected LiquidSettingsScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.liquid_settings_title")); + this.parent = parent; + this.config = config; + this.liquidTankCapacityMl = config.getLiquidTankCapacityMl(); + this.liquidDecay = config.getLiquidDecayPerSecond(); + this.peakXpPerMl = config.getPeakXpPerMl(); + this.filledStageOneSpeed = config.getFilledStageOneSpeedMult(); + this.filledStageTwoSpeed = config.getFilledStageTwoSpeedMult(); + this.filledStageThreeSpeed = config.getFilledStageThreeSpeedMult(); + this.filledStageOneJump = config.getFilledStageOneJumpMult(); + this.filledStageTwoJump = config.getFilledStageTwoJumpMult(); + this.filledStageThreeJump = config.getFilledStageThreeJumpMult(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.liquidTankCapacityField != null) { + this.liquidTankCapacityMl = this.parseField(this.liquidTankCapacityField, this.liquidTankCapacityMl); + this.liquidDecay = this.parseDecimalField(this.liquidDecayField, this.liquidDecay); + this.peakXpPerMl = this.parseDecimalField(this.peakXpPerMlField, this.peakXpPerMl); + this.filledStageOneSpeed = this.parseDecimalField(this.filledStageOneSpeedField, this.filledStageOneSpeed); + this.filledStageTwoSpeed = this.parseDecimalField(this.filledStageTwoSpeedField, this.filledStageTwoSpeed); + this.filledStageThreeSpeed = this.parseDecimalField(this.filledStageThreeSpeedField, this.filledStageThreeSpeed); + this.filledStageOneJump = this.parseDecimalField(this.filledStageOneJumpField, this.filledStageOneJump); + this.filledStageTwoJump = this.parseDecimalField(this.filledStageTwoJumpField, this.filledStageTwoJump); + this.filledStageThreeJump = this.parseDecimalField(this.filledStageThreeJumpField, this.filledStageThreeJump); + } + + int listTop = 32; + int bottomArea = 88; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.liquid_tank"))); + this.liquidTankCapacityField = this.newNumberField(fieldWidth, this.liquidTankCapacityMl); + this.resetLiquidTankCapacityButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.liquidTankCapacityField.setText(String.valueOf(this.defaults.getLiquidTankCapacityMl())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLiquidTankCapacityButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.liquid_tank_capacity"), + this.liquidTankCapacityField, + this.resetLiquidTankCapacityButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.liquid_tank_capacity") + ) + ); + this.liquidDecayField = this.newDecimalField(fieldWidth, this.liquidDecay); + this.resetLiquidDecayButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.liquidDecayField.setText(String.valueOf(this.defaults.getLiquidDecayPerSecond())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetLiquidDecayButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.liquid_decay"), + this.liquidDecayField, + this.resetLiquidDecayButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.liquid_decay") + ) + ); + this.peakXpPerMlField = this.newDecimalField(fieldWidth, this.peakXpPerMl); + this.resetPeakXpPerMlButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), button -> this.peakXpPerMlField.setText(String.valueOf(this.defaults.getPeakXpPerMl())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPeakXpPerMlButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.peak_xp_per_ml"), + this.peakXpPerMlField, + this.resetPeakXpPerMlButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.peak_xp_per_ml") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.filled_penalties"))); + this.filledStageOneSpeedField = this.newDecimalField(fieldWidth, this.filledStageOneSpeed); + this.resetFilledStageOneSpeedButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageOneSpeedField.setText(String.valueOf(this.defaults.getFilledStageOneSpeedMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageOneSpeedButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_one_speed"), + this.filledStageOneSpeedField, + this.resetFilledStageOneSpeedButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_one_speed") + ) + ); + this.filledStageTwoSpeedField = this.newDecimalField(fieldWidth, this.filledStageTwoSpeed); + this.resetFilledStageTwoSpeedButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageTwoSpeedField.setText(String.valueOf(this.defaults.getFilledStageTwoSpeedMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageTwoSpeedButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_two_speed"), + this.filledStageTwoSpeedField, + this.resetFilledStageTwoSpeedButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_two_speed") + ) + ); + this.filledStageThreeSpeedField = this.newDecimalField(fieldWidth, this.filledStageThreeSpeed); + this.resetFilledStageThreeSpeedButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageThreeSpeedField.setText(String.valueOf(this.defaults.getFilledStageThreeSpeedMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageThreeSpeedButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_three_speed"), + this.filledStageThreeSpeedField, + this.resetFilledStageThreeSpeedButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_three_speed") + ) + ); + this.filledStageOneJumpField = this.newDecimalField(fieldWidth, this.filledStageOneJump); + this.resetFilledStageOneJumpButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageOneJumpField.setText(String.valueOf(this.defaults.getFilledStageOneJumpMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageOneJumpButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_one_jump"), + this.filledStageOneJumpField, + this.resetFilledStageOneJumpButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_one_jump") + ) + ); + this.filledStageTwoJumpField = this.newDecimalField(fieldWidth, this.filledStageTwoJump); + this.resetFilledStageTwoJumpButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageTwoJumpField.setText(String.valueOf(this.defaults.getFilledStageTwoJumpMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageTwoJumpButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_two_jump"), + this.filledStageTwoJumpField, + this.resetFilledStageTwoJumpButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_two_jump") + ) + ); + this.filledStageThreeJumpField = this.newDecimalField(fieldWidth, this.filledStageThreeJump); + this.resetFilledStageThreeJumpButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.filledStageThreeJumpField.setText(String.valueOf(this.defaults.getFilledStageThreeJumpMult())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetFilledStageThreeJumpButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.filled_stage_three_jump"), + this.filledStageThreeJumpField, + this.resetFilledStageThreeJumpButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.filled_stage_three_jump") + ) + ); + this.liquidTankCapacityField.setEditable(this.serverConfigEditable); + this.liquidDecayField.setEditable(this.serverConfigEditable); + this.peakXpPerMlField.setEditable(this.serverConfigEditable); + this.filledStageOneSpeedField.setEditable(this.serverConfigEditable); + this.filledStageTwoSpeedField.setEditable(this.serverConfigEditable); + this.filledStageThreeSpeedField.setEditable(this.serverConfigEditable); + this.filledStageOneJumpField.setEditable(this.serverConfigEditable); + this.filledStageTwoJumpField.setEditable(this.serverConfigEditable); + this.filledStageThreeJumpField.setEditable(this.serverConfigEditable); + this.liquidTankCapacityField.setChangedListener(ignored -> this.updateResetButtons()); + this.liquidDecayField.setChangedListener(ignored -> this.updateResetButtons()); + this.peakXpPerMlField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageOneSpeedField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageTwoSpeedField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageThreeSpeedField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageOneJumpField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageTwoJumpField.setChangedListener(ignored -> this.updateResetButtons()); + this.filledStageThreeJumpField.setChangedListener(ignored -> this.updateResetButtons()); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget liquidGainButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.liquid_gain_button"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.LiquidGainConfigScreen(this, this.config)) + ) + .dimensions(centerX - 100, this.height - 52, 200, 20) + .build(); + NonModMenuScreens.setTooltip(liquidGainButton, "config.needsofnature.tooltip.liquid_gain_button"); + this.addDrawableChild(liquidGainButton); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip(doneButton, "config.needsofnature.tooltip.done_save"); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + this.liquidTankCapacityMl = this.clampLiquidTankCapacityMl(this.parseField(this.liquidTankCapacityField, this.liquidTankCapacityMl)); + this.liquidDecay = this.clampLiquidDecay(this.parseDecimalField(this.liquidDecayField, this.liquidDecay)); + this.peakXpPerMl = this.clampPeakXpPerMl(this.parseDecimalField(this.peakXpPerMlField, this.peakXpPerMl)); + this.filledStageOneSpeed = this.clampMultiplier(this.parseDecimalField(this.filledStageOneSpeedField, this.filledStageOneSpeed)); + this.filledStageTwoSpeed = this.clampMultiplier(this.parseDecimalField(this.filledStageTwoSpeedField, this.filledStageTwoSpeed)); + this.filledStageThreeSpeed = this.clampMultiplier(this.parseDecimalField(this.filledStageThreeSpeedField, this.filledStageThreeSpeed)); + this.filledStageOneJump = this.clampMultiplier(this.parseDecimalField(this.filledStageOneJumpField, this.filledStageOneJump)); + this.filledStageTwoJump = this.clampMultiplier(this.parseDecimalField(this.filledStageTwoJumpField, this.filledStageTwoJump)); + this.filledStageThreeJump = this.clampMultiplier(this.parseDecimalField(this.filledStageThreeJumpField, this.filledStageThreeJump)); + if (this.serverConfigEditable) { + this.config.setLiquidTankCapacityMl(this.liquidTankCapacityMl); + this.config.setLiquidDecayPerSecond(this.liquidDecay); + this.config.setPeakXpPerMl(this.peakXpPerMl); + this.config.setFilledStageOneSpeedMult((float)this.filledStageOneSpeed); + this.config.setFilledStageTwoSpeedMult((float)this.filledStageTwoSpeed); + this.config.setFilledStageThreeSpeedMult((float)this.filledStageThreeSpeed); + this.config.setFilledStageOneJumpMult((float)this.filledStageOneJump); + this.config.setFilledStageTwoJumpMult((float)this.filledStageTwoJump); + this.config.setFilledStageThreeJumpMult((float)this.filledStageThreeJump); + } + + if (this.serverConfigEditable) { + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private TextFieldWidget newDecimalField(int w, double initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + int maxLen = 16; + field.setMaxLength(maxLen); + field.setText(this.trimToMax(this.formatDecimal(initial), maxLen)); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private double parseDecimalField(TextFieldWidget field, double fallback) { + try { + return Double.parseDouble(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private String formatDecimal(double value) { + String text = String.format(Locale.ROOT, "%.3f", value); + int dot = text.indexOf(46); + if (dot >= 0) { + int end = text.length(); + + while (end > dot + 1 && text.charAt(end - 1) == '0') { + end--; + } + + if (end == dot + 1) { + end = dot; + } + + text = text.substring(0, end); + } + + return text; + } + + private String trimToMax(String value, int max) { + if (value == null) { + return ""; + } else { + return value.length() > max ? value.substring(0, max) : value; + } + } + + private double clampLiquidDecay(double v) { + return Math.max(0.0, Math.min(20.0, v)); + } + + private double clampPeakXpPerMl(double v) { + return Math.max(0.0, Math.min(10.0, v)); + } + + private int clampLiquidTankCapacityMl(int v) { + return Math.max(1, Math.min(10000, v)); + } + + private double clampMultiplier(double v) { + return Math.max(0.1, Math.min(1.0, v)); + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseField(field, defaultValue) != defaultValue; + } + + private boolean isDoubleFieldChanged(TextFieldWidget field, double defaultValue) { + if (field == null) { + return false; + } + + double value = this.parseDecimalField(field, defaultValue); + return Math.abs(value - defaultValue) >= 1.0E-4; + } + + private void updateResetButtons() { + if (this.resetLiquidTankCapacityButton != null) { + this.resetLiquidTankCapacityButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.liquidTankCapacityField, this.defaults.getLiquidTankCapacityMl()); + } + + if (this.resetLiquidDecayButton != null) { + this.resetLiquidDecayButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.liquidDecayField, this.defaults.getLiquidDecayPerSecond()); + } + + if (this.resetPeakXpPerMlButton != null) { + this.resetPeakXpPerMlButton.active = this.serverConfigEditable && this.isDoubleFieldChanged(this.peakXpPerMlField, this.defaults.getPeakXpPerMl()); + } + + if (this.resetFilledStageOneSpeedButton != null) { + this.resetFilledStageOneSpeedButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageOneSpeedField, this.defaults.getFilledStageOneSpeedMult()); + } + + if (this.resetFilledStageTwoSpeedButton != null) { + this.resetFilledStageTwoSpeedButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageTwoSpeedField, this.defaults.getFilledStageTwoSpeedMult()); + } + + if (this.resetFilledStageThreeSpeedButton != null) { + this.resetFilledStageThreeSpeedButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageThreeSpeedField, this.defaults.getFilledStageThreeSpeedMult()); + } + + if (this.resetFilledStageOneJumpButton != null) { + this.resetFilledStageOneJumpButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageOneJumpField, this.defaults.getFilledStageOneJumpMult()); + } + + if (this.resetFilledStageTwoJumpButton != null) { + this.resetFilledStageTwoJumpButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageTwoJumpField, this.defaults.getFilledStageTwoJumpMult()); + } + + if (this.resetFilledStageThreeJumpButton != null) { + this.resetFilledStageThreeJumpButton.active = this.serverConfigEditable + && this.isDoubleFieldChanged(this.filledStageThreeJumpField, this.defaults.getFilledStageThreeJumpMult()); + } + } + } + + static class PregnancySettingsScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean serverConfigEditable; + private int pregnancyChancePercent; + private int pregnancyDurationMinutes; + private int pregnancyEggHatchMinutes; + private boolean pregnancyAutoTameMobs; + private boolean pregnancyFriendlyMobsIgnorePlayers; + private boolean liquidBasedPregnancy; + private int pregnancyRiskyStartDay; + private int pregnancyOvulationEndDay; + private int pregnancySafeDayChancePercent; + private int pregnancyRiskyChancePercent; + private int pregnancyOvulationChancePercent; + private TextFieldWidget pregnancyChanceField; + private TextFieldWidget pregnancyDurationField; + private TextFieldWidget pregnancyEggHatchMinutesField; + private TextFieldWidget pregnancyRiskyStartDayField; + private TextFieldWidget pregnancyOvulationEndDayField; + private TextFieldWidget pregnancySafeDayChancePercentField; + private TextFieldWidget pregnancyRiskyChancePercentField; + private TextFieldWidget pregnancyOvulationChancePercentField; + private ButtonWidget pregnancyAutoTameMobsButton; + private ButtonWidget pregnancyFriendlyMobsIgnorePlayersButton; + private ButtonWidget liquidBasedPregnancyButton; + private ButtonWidget resetPregnancyChanceButton; + private ButtonWidget resetPregnancyDurationButton; + private ButtonWidget resetPregnancyEggHatchMinutesButton; + private ButtonWidget resetPregnancyAutoTameMobsButton; + private ButtonWidget resetPregnancyFriendlyMobsIgnorePlayersButton; + private ButtonWidget resetLiquidBasedPregnancyButton; + private ButtonWidget resetPregnancyRiskyStartDayButton; + private ButtonWidget resetPregnancyOvulationEndDayButton; + private ButtonWidget resetPregnancySafeDayChancePercentButton; + private ButtonWidget resetPregnancyRiskyChancePercentButton; + private ButtonWidget resetPregnancyOvulationChancePercentButton; + + protected PregnancySettingsScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.pregnancy_title")); + this.parent = parent; + this.config = config; + this.pregnancyChancePercent = config.getPregnancyChancePercent(); + this.pregnancyDurationMinutes = config.getPregnancyDurationMinutes(); + this.pregnancyEggHatchMinutes = this.eggHatchSecondsToMinutes(config.getPregnancyEggDefaultHatchSeconds()); + this.pregnancyAutoTameMobs = config.isPregnancyAutoTameMobs(); + this.pregnancyFriendlyMobsIgnorePlayers = config.pregnancyFriendlyMobsIgnorePlayers(); + this.liquidBasedPregnancy = config.isLiquidBasedPregnancyEnabled(); + this.pregnancyRiskyStartDay = config.getPregnancyRiskyStartDay(); + this.pregnancyOvulationEndDay = config.getPregnancyOvulationEndDay(); + this.pregnancySafeDayChancePercent = config.getPregnancySafeDayChancePercent(); + this.pregnancyRiskyChancePercent = config.getPregnancyRiskyChancePercent(); + this.pregnancyOvulationChancePercent = config.getPregnancyOvulationChancePercent(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + if (this.pregnancyChanceField != null) { + this.pregnancyChancePercent = this.parseField(this.pregnancyChanceField, this.pregnancyChancePercent); + this.pregnancyDurationMinutes = this.parseField(this.pregnancyDurationField, this.pregnancyDurationMinutes); + this.pregnancyEggHatchMinutes = this.parseField(this.pregnancyEggHatchMinutesField, this.pregnancyEggHatchMinutes); + this.pregnancyRiskyStartDay = this.parseField(this.pregnancyRiskyStartDayField, this.pregnancyRiskyStartDay); + this.pregnancyOvulationEndDay = this.parseField(this.pregnancyOvulationEndDayField, this.pregnancyOvulationEndDay); + this.pregnancySafeDayChancePercent = this.parseField(this.pregnancySafeDayChancePercentField, this.pregnancySafeDayChancePercent); + this.pregnancyRiskyChancePercent = this.parseField(this.pregnancyRiskyChancePercentField, this.pregnancyRiskyChancePercent); + this.pregnancyOvulationChancePercent = this.parseField(this.pregnancyOvulationChancePercentField, this.pregnancyOvulationChancePercent); + } + + int listTop = 32; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int fieldWidth = 50; + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.pregnancy_timing"))); + this.pregnancyChanceField = this.newNumberField(fieldWidth, this.pregnancyChancePercent); + this.resetPregnancyChanceButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyChanceField.setText(String.valueOf(this.defaults.getPregnancyChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyChanceButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_chance_percent"), + this.pregnancyChanceField, + this.resetPregnancyChanceButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_chance_percent") + ) + ); + this.pregnancyDurationField = this.newNumberField(fieldWidth, this.pregnancyDurationMinutes); + this.resetPregnancyDurationButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyDurationField.setText(String.valueOf(this.defaults.getPregnancyDurationMinutes())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyDurationButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_duration_minutes"), + this.pregnancyDurationField, + this.resetPregnancyDurationButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_duration_minutes") + ) + ); + this.pregnancyEggHatchMinutesField = this.newNumberField(fieldWidth, this.pregnancyEggHatchMinutes); + this.resetPregnancyEggHatchMinutesButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyEggHatchMinutesField + .setText(String.valueOf(this.eggHatchSecondsToMinutes(this.defaults.getPregnancyEggDefaultHatchSeconds()))) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyEggHatchMinutesButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_egg_hatch_minutes"), + this.pregnancyEggHatchMinutesField, + this.resetPregnancyEggHatchMinutesButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_egg_hatch_minutes") + ) + ); + settingsList.addEntryRow( + SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.liquid_based_pregnancy")) + ); + this.liquidBasedPregnancyButton = ButtonWidget.builder(this.toggleText(this.liquidBasedPregnancy), button -> { + this.liquidBasedPregnancy = !this.liquidBasedPregnancy; + button.setMessage(this.toggleText(this.liquidBasedPregnancy)); + this.updateLiquidPregnancyControlState(); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.liquidBasedPregnancyButton, "config.needsofnature.tooltip.liquid_based_pregnancy"); + this.resetLiquidBasedPregnancyButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.liquidBasedPregnancy = this.defaults.isLiquidBasedPregnancyEnabled(); + this.liquidBasedPregnancyButton.setMessage(this.toggleText(this.liquidBasedPregnancy)); + this.updateLiquidPregnancyControlState(); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetLiquidBasedPregnancyButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.liquid_based_pregnancy"), + this.liquidBasedPregnancyButton, + this.resetLiquidBasedPregnancyButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.liquid_based_pregnancy") + ) + ); + this.pregnancyRiskyStartDayField = this.newNumberField(fieldWidth, this.pregnancyRiskyStartDay); + this.resetPregnancyRiskyStartDayButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyRiskyStartDayField.setText(String.valueOf(this.defaults.getPregnancyRiskyStartDay())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyRiskyStartDayButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_risky_start_day"), + this.pregnancyRiskyStartDayField, + this.resetPregnancyRiskyStartDayButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_risky_start_day") + ) + ); + this.pregnancyOvulationEndDayField = this.newNumberField(fieldWidth, this.pregnancyOvulationEndDay); + this.resetPregnancyOvulationEndDayButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyOvulationEndDayField.setText(String.valueOf(this.defaults.getPregnancyOvulationEndDay())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyOvulationEndDayButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_ovulation_end_day"), + this.pregnancyOvulationEndDayField, + this.resetPregnancyOvulationEndDayButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_ovulation_end_day") + ) + ); + this.pregnancySafeDayChancePercentField = this.newNumberField(fieldWidth, this.pregnancySafeDayChancePercent); + this.resetPregnancySafeDayChancePercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancySafeDayChancePercentField.setText(String.valueOf(this.defaults.getPregnancySafeDayChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancySafeDayChancePercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_safe_day_chance_percent"), + this.pregnancySafeDayChancePercentField, + this.resetPregnancySafeDayChancePercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_safe_day_chance_percent") + ) + ); + this.pregnancyRiskyChancePercentField = this.newNumberField(fieldWidth, this.pregnancyRiskyChancePercent); + this.resetPregnancyRiskyChancePercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyRiskyChancePercentField.setText(String.valueOf(this.defaults.getPregnancyRiskyChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyRiskyChancePercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_risky_chance_percent"), + this.pregnancyRiskyChancePercentField, + this.resetPregnancyRiskyChancePercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_risky_chance_percent") + ) + ); + this.pregnancyOvulationChancePercentField = this.newNumberField(fieldWidth, this.pregnancyOvulationChancePercent); + this.resetPregnancyOvulationChancePercentButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.reset_icon"), + button -> this.pregnancyOvulationChancePercentField.setText(String.valueOf(this.defaults.getPregnancyOvulationChancePercent())) + ) + .dimensions(0, 0, resetW, 20) + .build(); + NonModMenuScreens.setTooltip(this.resetPregnancyOvulationChancePercentButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_ovulation_chance_percent"), + this.pregnancyOvulationChancePercentField, + this.resetPregnancyOvulationChancePercentButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_ovulation_chance_percent") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.pregnancy_behavior"))); + this.pregnancyAutoTameMobsButton = ButtonWidget.builder(this.toggleText(this.pregnancyAutoTameMobs), button -> { + this.pregnancyAutoTameMobs = !this.pregnancyAutoTameMobs; + button.setMessage(this.toggleText(this.pregnancyAutoTameMobs)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetPregnancyAutoTameMobsButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.pregnancyAutoTameMobs = this.defaults.isPregnancyAutoTameMobs(); + this.pregnancyAutoTameMobsButton.setMessage(this.toggleText(this.pregnancyAutoTameMobs)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetPregnancyAutoTameMobsButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_auto_tame_mobs"), + this.pregnancyAutoTameMobsButton, + this.resetPregnancyAutoTameMobsButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_auto_tame_mobs") + ) + ); + this.pregnancyFriendlyMobsIgnorePlayersButton = ButtonWidget.builder(this.toggleText(this.pregnancyFriendlyMobsIgnorePlayers), button -> { + this.pregnancyFriendlyMobsIgnorePlayers = !this.pregnancyFriendlyMobsIgnorePlayers; + button.setMessage(this.toggleText(this.pregnancyFriendlyMobsIgnorePlayers)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetPregnancyFriendlyMobsIgnorePlayersButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.pregnancyFriendlyMobsIgnorePlayers = this.defaults.pregnancyFriendlyMobsIgnorePlayers(); + this.pregnancyFriendlyMobsIgnorePlayersButton.setMessage(this.toggleText(this.pregnancyFriendlyMobsIgnorePlayers)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetPregnancyFriendlyMobsIgnorePlayersButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.pregnancy_friendly_mobs_ignore_players"), + this.pregnancyFriendlyMobsIgnorePlayersButton, + this.resetPregnancyFriendlyMobsIgnorePlayersButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.pregnancy_friendly_mobs_ignore_players") + ) + ); + this.pregnancyChanceField.setEditable(this.serverConfigEditable); + this.pregnancyDurationField.setEditable(this.serverConfigEditable); + this.pregnancyEggHatchMinutesField.setEditable(this.serverConfigEditable); + this.pregnancyAutoTameMobsButton.active = this.serverConfigEditable; + this.pregnancyFriendlyMobsIgnorePlayersButton.active = this.serverConfigEditable; + this.updateLiquidPregnancyControlState(); + this.pregnancyChanceField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyDurationField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyEggHatchMinutesField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyRiskyStartDayField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyOvulationEndDayField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancySafeDayChancePercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyRiskyChancePercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.pregnancyOvulationChancePercentField.setChangedListener(ignored -> this.updateResetButtons()); + this.updateResetButtons(); + int centerX = this.width / 2; + ButtonWidget offspringCountButton = ButtonWidget.builder( + Text.translatable("config.needsofnature.offspring_count_button"), + button -> MinecraftClient.getInstance().setScreen(new NonModMenuDebugScreens.OffspringCountConfigScreen(this, this.config)) + ) + .dimensions(centerX - 100, this.height - 52, 200, 20) + .build(); + NonModMenuScreens.setTooltip(offspringCountButton, "config.needsofnature.tooltip.offspring_count_button"); + this.addDrawableChild(offspringCountButton); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.pregnancyChancePercent = this.clampPercent(this.parseField(this.pregnancyChanceField, this.pregnancyChancePercent)); + this.pregnancyDurationMinutes = this.clampPregnancyDurationMinutes(this.parseField(this.pregnancyDurationField, this.pregnancyDurationMinutes)); + this.pregnancyEggHatchMinutes = this.clampPregnancyEggHatchMinutes( + this.parseField(this.pregnancyEggHatchMinutesField, this.pregnancyEggHatchMinutes) + ); + this.pregnancyRiskyStartDay = this.clampPregnancyRiskyStartDay(this.parseField(this.pregnancyRiskyStartDayField, this.pregnancyRiskyStartDay)); + this.pregnancyOvulationEndDay = Math.max( + this.pregnancyRiskyStartDay + 2, this.clampPregnancyCycleDay(this.parseField(this.pregnancyOvulationEndDayField, this.pregnancyOvulationEndDay)) + ); + this.pregnancySafeDayChancePercent = this.clampPregnancyCycleChancePercent( + this.parseField(this.pregnancySafeDayChancePercentField, this.pregnancySafeDayChancePercent) + ); + this.pregnancyRiskyChancePercent = this.clampPregnancyCycleChancePercent( + this.parseField(this.pregnancyRiskyChancePercentField, this.pregnancyRiskyChancePercent) + ); + this.pregnancyOvulationChancePercent = this.clampPregnancyCycleChancePercent( + this.parseField(this.pregnancyOvulationChancePercentField, this.pregnancyOvulationChancePercent) + ); + this.config.setPregnancyChancePercent(this.pregnancyChancePercent); + this.config.setPregnancyDurationMinutes(this.pregnancyDurationMinutes); + this.config.setPregnancyEggDefaultHatchSeconds(this.eggHatchMinutesToSeconds(this.pregnancyEggHatchMinutes)); + this.config.setPregnancyAutoTameMobs(this.pregnancyAutoTameMobs); + this.config.setPregnancyFriendlyMobsIgnorePlayers(this.pregnancyFriendlyMobsIgnorePlayers); + this.config.setLiquidBasedPregnancyEnabled(this.liquidBasedPregnancy); + this.config.setPregnancyRiskyStartDay(this.pregnancyRiskyStartDay); + this.config.setPregnancyOvulationEndDay(this.pregnancyOvulationEndDay); + this.config.setPregnancySafeDayChancePercent(this.pregnancySafeDayChancePercent); + this.config.setPregnancyRiskyChancePercent(this.pregnancyRiskyChancePercent); + this.config.setPregnancyOvulationChancePercent(this.pregnancyOvulationChancePercent); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private TextFieldWidget newNumberField(int w, int initial) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, 0, 0, w, 20, Text.literal("")); + field.setText(String.valueOf(initial)); + field.setMaxLength(8); + field.setEditable(true); + return field; + } + + private int parseField(TextFieldWidget field, int fallback) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private int clampPercent(int v) { + return Math.max(0, Math.min(100, v)); + } + + private int clampPregnancyDurationMinutes(int v) { + return Math.max(1, Math.min(5256000, v)); + } + + private int clampPregnancyEggHatchMinutes(int v) { + return Math.max(1, Math.min(1200, v)); + } + + private int clampPregnancyCycleDay(int value) { + return Math.max(1, Math.min(30, value)); + } + + private int clampPregnancyRiskyStartDay(int value) { + return Math.max(1, Math.min(28, value)); + } + + private int clampPregnancyCycleChancePercent(int value) { + return Math.max(0, Math.min(1000, value)); + } + + private int eggHatchSecondsToMinutes(int seconds) { + return Math.max(1, (int)Math.ceil(seconds / 60.0)); + } + + private int eggHatchMinutesToSeconds(int minutes) { + int clamped = this.clampPregnancyEggHatchMinutes(minutes); + return Math.max(1, Math.min(72000, clamped * 60)); + } + + private boolean isIntFieldChanged(TextFieldWidget field, int defaultValue) { + return field == null ? false : this.parseField(field, defaultValue) != defaultValue; + } + + private Text toggleText(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + + private void updateLiquidPregnancyControlState() { + boolean systemsEnabled = this.config.isPregnancyEnabled() && this.config.isLiquidTankEnabled(); + boolean detailsEditable = this.serverConfigEditable && systemsEnabled && this.liquidBasedPregnancy; + if (this.liquidBasedPregnancyButton != null) { + this.liquidBasedPregnancyButton.active = this.serverConfigEditable && systemsEnabled; + } + + if (this.pregnancyRiskyStartDayField != null) { + this.pregnancyRiskyStartDayField.setEditable(detailsEditable); + } + + if (this.pregnancyOvulationEndDayField != null) { + this.pregnancyOvulationEndDayField.setEditable(detailsEditable); + } + + if (this.pregnancySafeDayChancePercentField != null) { + this.pregnancySafeDayChancePercentField.setEditable(detailsEditable); + } + + if (this.pregnancyRiskyChancePercentField != null) { + this.pregnancyRiskyChancePercentField.setEditable(detailsEditable); + } + + if (this.pregnancyOvulationChancePercentField != null) { + this.pregnancyOvulationChancePercentField.setEditable(detailsEditable); + } + } + + private void updateResetButtons() { + if (this.resetPregnancyChanceButton != null) { + this.resetPregnancyChanceButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pregnancyChanceField, this.defaults.getPregnancyChancePercent()); + } + + if (this.resetPregnancyDurationButton != null) { + this.resetPregnancyDurationButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pregnancyDurationField, this.defaults.getPregnancyDurationMinutes()); + } + + if (this.resetPregnancyEggHatchMinutesButton != null) { + this.resetPregnancyEggHatchMinutesButton.active = this.serverConfigEditable + && this.isIntFieldChanged(this.pregnancyEggHatchMinutesField, this.eggHatchSecondsToMinutes(this.defaults.getPregnancyEggDefaultHatchSeconds())); + } + + if (this.resetPregnancyAutoTameMobsButton != null) { + this.resetPregnancyAutoTameMobsButton.active = this.serverConfigEditable && this.pregnancyAutoTameMobs != this.defaults.isPregnancyAutoTameMobs(); + } + + if (this.resetPregnancyFriendlyMobsIgnorePlayersButton != null) { + this.resetPregnancyFriendlyMobsIgnorePlayersButton.active = this.serverConfigEditable + && this.pregnancyFriendlyMobsIgnorePlayers != this.defaults.pregnancyFriendlyMobsIgnorePlayers(); + } + + if (this.resetLiquidBasedPregnancyButton != null) { + this.resetLiquidBasedPregnancyButton.active = this.serverConfigEditable + && this.liquidBasedPregnancy != this.defaults.isLiquidBasedPregnancyEnabled(); + } + + boolean liquidPregnancyDetailsEditable = this.serverConfigEditable + && this.config.isPregnancyEnabled() + && this.config.isLiquidTankEnabled() + && this.liquidBasedPregnancy; + if (this.resetPregnancyRiskyStartDayButton != null) { + this.resetPregnancyRiskyStartDayButton.active = liquidPregnancyDetailsEditable + && this.isIntFieldChanged(this.pregnancyRiskyStartDayField, this.defaults.getPregnancyRiskyStartDay()); + } + + if (this.resetPregnancyOvulationEndDayButton != null) { + this.resetPregnancyOvulationEndDayButton.active = liquidPregnancyDetailsEditable + && this.isIntFieldChanged(this.pregnancyOvulationEndDayField, this.defaults.getPregnancyOvulationEndDay()); + } + + if (this.resetPregnancySafeDayChancePercentButton != null) { + this.resetPregnancySafeDayChancePercentButton.active = liquidPregnancyDetailsEditable + && this.isIntFieldChanged(this.pregnancySafeDayChancePercentField, this.defaults.getPregnancySafeDayChancePercent()); + } + + if (this.resetPregnancyRiskyChancePercentButton != null) { + this.resetPregnancyRiskyChancePercentButton.active = liquidPregnancyDetailsEditable + && this.isIntFieldChanged(this.pregnancyRiskyChancePercentField, this.defaults.getPregnancyRiskyChancePercent()); + } + + if (this.resetPregnancyOvulationChancePercentButton != null) { + this.resetPregnancyOvulationChancePercentButton.active = liquidPregnancyDetailsEditable + && this.isIntFieldChanged(this.pregnancyOvulationChancePercentField, this.defaults.getPregnancyOvulationChancePercent()); + } + } + } + + static class ToggleSettingsScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean pregnancyEnabled; + private boolean liquidTankEnabled; + private boolean messSystemEnabled; + private boolean destroyedSkinSystemEnabled; + private boolean vanillaOverridesEnabled; + private ButtonWidget pregnancyEnabledButton; + private ButtonWidget resetPregnancyEnabledButton; + private ButtonWidget liquidTankEnabledButton; + private ButtonWidget resetLiquidTankEnabledButton; + private ButtonWidget messSystemEnabledButton; + private ButtonWidget resetMessSystemEnabledButton; + private ButtonWidget destroyedSkinSystemEnabledButton; + private ButtonWidget resetDestroyedSkinSystemEnabledButton; + private ButtonWidget vanillaOverridesEnabledButton; + private ButtonWidget resetVanillaOverridesEnabledButton; + private boolean serverConfigEditable; + + protected ToggleSettingsScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.toggles_title")); + this.parent = parent; + this.config = config; + this.pregnancyEnabled = config.isPregnancyEnabled(); + this.liquidTankEnabled = config.isLiquidTankEnabled(); + this.messSystemEnabled = config.isMessSystemEnabled(); + this.destroyedSkinSystemEnabled = config.isDestroyedSkinSystemEnabled(); + this.vanillaOverridesEnabled = config.isVanillaOverridesEnabled(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + int centerX = this.width / 2; + int listTop = 32; + int bottomArea = 64; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.core_systems"))); + this.pregnancyEnabledButton = ButtonWidget.builder(this.toggleWithValue(this.pregnancyEnabled), button -> { + this.pregnancyEnabled = !this.pregnancyEnabled; + button.setMessage(this.toggleWithValue(this.pregnancyEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.pregnancyEnabledButton, "config.needsofnature.tooltip.toggle.pregnancy"); + this.resetPregnancyEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.pregnancyEnabled = this.defaults.isPregnancyEnabled(); + this.pregnancyEnabledButton.setMessage(this.toggleWithValue(this.pregnancyEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetPregnancyEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.toggle.pregnancy"), + this.pregnancyEnabledButton, + this.resetPregnancyEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.toggle.pregnancy") + ) + ); + this.liquidTankEnabledButton = ButtonWidget.builder(this.toggleWithValue(this.liquidTankEnabled), button -> { + this.liquidTankEnabled = !this.liquidTankEnabled; + button.setMessage(this.toggleWithValue(this.liquidTankEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.liquidTankEnabledButton, "config.needsofnature.tooltip.toggle.liquid_tank"); + this.resetLiquidTankEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.liquidTankEnabled = this.defaults.isLiquidTankEnabled(); + this.liquidTankEnabledButton.setMessage(this.toggleWithValue(this.liquidTankEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetLiquidTankEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.toggle.liquid_tank"), + this.liquidTankEnabledButton, + this.resetLiquidTankEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.toggle.liquid_tank") + ) + ); + this.messSystemEnabledButton = ButtonWidget.builder(this.toggleWithValue(this.messSystemEnabled), button -> { + this.messSystemEnabled = !this.messSystemEnabled; + button.setMessage(this.toggleWithValue(this.messSystemEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.messSystemEnabledButton, "config.needsofnature.tooltip.toggle.mess_system"); + this.resetMessSystemEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.messSystemEnabled = this.defaults.isMessSystemEnabled(); + this.messSystemEnabledButton.setMessage(this.toggleWithValue(this.messSystemEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetMessSystemEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.toggle.mess_system"), + this.messSystemEnabledButton, + this.resetMessSystemEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.toggle.mess_system") + ) + ); + this.destroyedSkinSystemEnabledButton = ButtonWidget.builder(this.toggleWithValue(this.destroyedSkinSystemEnabled), button -> { + this.destroyedSkinSystemEnabled = !this.destroyedSkinSystemEnabled; + button.setMessage(this.toggleWithValue(this.destroyedSkinSystemEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.destroyedSkinSystemEnabledButton, "config.needsofnature.tooltip.toggle.destroyed_skin_system"); + this.resetDestroyedSkinSystemEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.destroyedSkinSystemEnabled = this.defaults.isDestroyedSkinSystemEnabled(); + this.destroyedSkinSystemEnabledButton.setMessage(this.toggleWithValue(this.destroyedSkinSystemEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetDestroyedSkinSystemEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.toggle.destroyed_skin_system"), + this.destroyedSkinSystemEnabledButton, + this.resetDestroyedSkinSystemEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.toggle.destroyed_skin_system") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.vanilla_integration"))); + this.vanillaOverridesEnabledButton = ButtonWidget.builder(this.toggleWithValue(this.vanillaOverridesEnabled), button -> { + this.vanillaOverridesEnabled = !this.vanillaOverridesEnabled; + button.setMessage(this.toggleWithValue(this.vanillaOverridesEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.vanillaOverridesEnabledButton, "config.needsofnature.tooltip.toggle.vanilla_overrides"); + this.resetVanillaOverridesEnabledButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.vanillaOverridesEnabled = this.defaults.isVanillaOverridesEnabled(); + this.vanillaOverridesEnabledButton.setMessage(this.toggleWithValue(this.vanillaOverridesEnabled)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetVanillaOverridesEnabledButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.toggle.vanilla_overrides"), + this.vanillaOverridesEnabledButton, + this.resetVanillaOverridesEnabledButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.toggle.vanilla_overrides") + ) + ); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + if (this.pregnancyEnabledButton != null) { + this.pregnancyEnabledButton.active = this.serverConfigEditable; + } + + if (this.liquidTankEnabledButton != null) { + this.liquidTankEnabledButton.active = this.serverConfigEditable; + } + + if (this.messSystemEnabledButton != null) { + this.messSystemEnabledButton.active = this.serverConfigEditable; + } + + if (this.destroyedSkinSystemEnabledButton != null) { + this.destroyedSkinSystemEnabledButton.active = this.serverConfigEditable; + } + + if (this.vanillaOverridesEnabledButton != null) { + this.vanillaOverridesEnabledButton.active = this.serverConfigEditable; + } + + this.updateResetButtons(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.config.setPregnancyEnabled(this.pregnancyEnabled); + this.config.setLiquidTankEnabled(this.liquidTankEnabled); + this.config.setMessSystemEnabled(this.messSystemEnabled); + this.config.setDestroyedSkinSystemEnabled(this.destroyedSkinSystemEnabled); + this.config.setVanillaOverridesEnabled(this.vanillaOverridesEnabled); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void updateResetButtons() { + if (this.resetPregnancyEnabledButton != null) { + this.resetPregnancyEnabledButton.active = this.serverConfigEditable && this.pregnancyEnabled != this.defaults.isPregnancyEnabled(); + } + + if (this.resetLiquidTankEnabledButton != null) { + this.resetLiquidTankEnabledButton.active = this.serverConfigEditable && this.liquidTankEnabled != this.defaults.isLiquidTankEnabled(); + } + + if (this.resetMessSystemEnabledButton != null) { + this.resetMessSystemEnabledButton.active = this.serverConfigEditable && this.messSystemEnabled != this.defaults.isMessSystemEnabled(); + } + + if (this.resetDestroyedSkinSystemEnabledButton != null) { + this.resetDestroyedSkinSystemEnabledButton.active = this.serverConfigEditable + && this.destroyedSkinSystemEnabled != this.defaults.isDestroyedSkinSystemEnabled(); + } + + if (this.resetVanillaOverridesEnabledButton != null) { + this.resetVanillaOverridesEnabledButton.active = this.serverConfigEditable + && this.vanillaOverridesEnabled != this.defaults.isVanillaOverridesEnabled(); + } + + if (this.pregnancyEnabledButton != null) { + this.pregnancyEnabledButton.active = this.serverConfigEditable; + } + + if (this.liquidTankEnabledButton != null) { + this.liquidTankEnabledButton.active = this.serverConfigEditable; + } + + if (this.messSystemEnabledButton != null) { + this.messSystemEnabledButton.active = this.serverConfigEditable; + } + + if (this.destroyedSkinSystemEnabledButton != null) { + this.destroyedSkinSystemEnabledButton.active = this.serverConfigEditable; + } + + if (this.vanillaOverridesEnabledButton != null) { + this.vanillaOverridesEnabledButton.active = this.serverConfigEditable; + } + } + + private Text toggleWithValue(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + } + + static class VanillaOverrideScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final NonConfig defaults = new NonConfig(); + private boolean useAnimationBreeding; + private boolean requireMaleFemaleForBreeding; + private boolean femaleCowOnlyMilking; + private ButtonWidget useAnimationBreedingButton; + private ButtonWidget resetUseAnimationBreedingButton; + private ButtonWidget requireMaleFemaleForBreedingButton; + private ButtonWidget resetRequireMaleFemaleForBreedingButton; + private ButtonWidget femaleCowOnlyMilkingButton; + private ButtonWidget resetFemaleCowOnlyMilkingButton; + private boolean serverConfigEditable; + + protected VanillaOverrideScreen(Screen parent, NonConfig config) { + super(Text.translatable("config.needsofnature.vanilla_override_title")); + this.parent = parent; + this.config = config; + this.useAnimationBreeding = config.useAnimationBreeding(); + this.requireMaleFemaleForBreeding = config.requireMaleFemaleForBreeding(); + this.femaleCowOnlyMilking = config.femaleCowOnlyMilking(); + } + + protected void init() { + this.serverConfigEditable = NonModMenuScreens.canEditServerGameplaySettings(); + int centerX = this.width / 2; + int listTop = 32; + int bottomArea = 88; + int listHeight = Math.max(0, this.height - listTop - bottomArea); + SettingsList settingsList = new SettingsList(this.client, this.width, listHeight, listTop); + this.addDrawableChild(settingsList); + int resetW = 20; + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.breeding"))); + this.useAnimationBreedingButton = ButtonWidget.builder(this.toggleWithValue(this.useAnimationBreeding), button -> { + this.useAnimationBreeding = !this.useAnimationBreeding; + button.setMessage(this.toggleWithValue(this.useAnimationBreeding)); + this.updateDependentTooltips(); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.useAnimationBreedingButton, "config.needsofnature.tooltip.use_animation_breeding"); + this.resetUseAnimationBreedingButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.useAnimationBreeding = this.defaults.useAnimationBreeding(); + this.useAnimationBreedingButton.setMessage(this.toggleWithValue(this.useAnimationBreeding)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetUseAnimationBreedingButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.use_animation_breeding"), + this.useAnimationBreedingButton, + this.resetUseAnimationBreedingButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.use_animation_breeding") + ) + ); + this.requireMaleFemaleForBreedingButton = ButtonWidget.builder(this.toggleWithValue(this.requireMaleFemaleForBreeding), button -> { + this.requireMaleFemaleForBreeding = !this.requireMaleFemaleForBreeding; + button.setMessage(this.toggleWithValue(this.requireMaleFemaleForBreeding)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + this.resetRequireMaleFemaleForBreedingButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.requireMaleFemaleForBreeding = this.defaults.requireMaleFemaleForBreeding(); + this.requireMaleFemaleForBreedingButton.setMessage(this.toggleWithValue(this.requireMaleFemaleForBreeding)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetRequireMaleFemaleForBreedingButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.require_male_female_breeding"), + this.requireMaleFemaleForBreedingButton, + this.resetRequireMaleFemaleForBreedingButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.require_male_female_breeding") + ) + ); + settingsList.addEntryRow(SettingsList.RowEntry.sectionHeader(this.textRenderer, Text.translatable("config.needsofnature.section.milking"))); + this.femaleCowOnlyMilkingButton = ButtonWidget.builder(this.toggleWithValue(this.femaleCowOnlyMilking), button -> { + this.femaleCowOnlyMilking = !this.femaleCowOnlyMilking; + button.setMessage(this.toggleWithValue(this.femaleCowOnlyMilking)); + this.updateResetButtons(); + }).dimensions(0, 0, 100, 20).build(); + NonModMenuScreens.setTooltip(this.femaleCowOnlyMilkingButton, "config.needsofnature.tooltip.female_cow_only_milking"); + this.resetFemaleCowOnlyMilkingButton = ButtonWidget.builder(Text.translatable("config.needsofnature.reset_icon"), button -> { + this.femaleCowOnlyMilking = this.defaults.femaleCowOnlyMilking(); + this.femaleCowOnlyMilkingButton.setMessage(this.toggleWithValue(this.femaleCowOnlyMilking)); + this.updateResetButtons(); + }).dimensions(0, 0, resetW, 20).build(); + NonModMenuScreens.setTooltip(this.resetFemaleCowOnlyMilkingButton, "config.needsofnature.tooltip.reset"); + settingsList.addEntryRow( + SettingsList.RowEntry.labeledField( + this.textRenderer, + Text.translatable("config.needsofnature.female_cow_only_milking"), + this.femaleCowOnlyMilkingButton, + this.resetFemaleCowOnlyMilkingButton, + NonModMenuScreens.tooltip("config.needsofnature.tooltip.female_cow_only_milking") + ) + ); + ButtonWidget doneButton = ButtonWidget.builder(Text.translatable("config.needsofnature.done"), button -> this.saveAndClose()) + .dimensions(centerX - 100, this.height - 28, 200, 20) + .build(); + NonModMenuScreens.setTooltip( + doneButton, this.serverConfigEditable ? "config.needsofnature.tooltip.done_save" : "config.needsofnature.tooltip.done_unsaved" + ); + this.addDrawableChild(doneButton); + if (this.useAnimationBreedingButton != null) { + this.useAnimationBreedingButton.active = this.serverConfigEditable; + } + + if (this.requireMaleFemaleForBreedingButton != null) { + this.requireMaleFemaleForBreedingButton.active = this.serverConfigEditable && this.useAnimationBreeding; + } + + if (this.femaleCowOnlyMilkingButton != null) { + this.femaleCowOnlyMilkingButton.active = this.serverConfigEditable; + } + + this.updateDependentTooltips(); + this.updateResetButtons(); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + this.updateResetButtons(); + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 20, 16777215); + } + + public void close() { + this.saveAndClose(); + } + + private void saveAndClose() { + if (this.serverConfigEditable) { + this.config.setUseAnimationBreeding(this.useAnimationBreeding); + this.config.setRequireMaleFemaleForBreeding(this.requireMaleFemaleForBreeding); + this.config.setFemaleCowOnlyMilking(this.femaleCowOnlyMilking); + NonModMenuScreens.saveServerConfig(this.config); + } + + MinecraftClient.getInstance().setScreen(this.parent); + } + + private void updateResetButtons() { + if (this.resetUseAnimationBreedingButton != null) { + this.resetUseAnimationBreedingButton.active = this.serverConfigEditable && this.useAnimationBreeding != this.defaults.useAnimationBreeding(); + } + + if (this.resetRequireMaleFemaleForBreedingButton != null) { + this.resetRequireMaleFemaleForBreedingButton.active = this.serverConfigEditable + && this.useAnimationBreeding + && this.requireMaleFemaleForBreeding != this.defaults.requireMaleFemaleForBreeding(); + } + + if (this.resetFemaleCowOnlyMilkingButton != null) { + this.resetFemaleCowOnlyMilkingButton.active = this.serverConfigEditable && this.femaleCowOnlyMilking != this.defaults.femaleCowOnlyMilking(); + } + + if (this.useAnimationBreedingButton != null) { + this.useAnimationBreedingButton.active = this.serverConfigEditable; + } + + if (this.requireMaleFemaleForBreedingButton != null) { + this.requireMaleFemaleForBreedingButton.active = this.serverConfigEditable && this.useAnimationBreeding; + } + + if (this.femaleCowOnlyMilkingButton != null) { + this.femaleCowOnlyMilkingButton.active = this.serverConfigEditable; + } + + this.updateDependentTooltips(); + } + + private void updateDependentTooltips() { + if (this.requireMaleFemaleForBreedingButton != null) { + NonModMenuScreens.setTooltip( + this.requireMaleFemaleForBreedingButton, + this.useAnimationBreeding + ? "config.needsofnature.tooltip.require_male_female_breeding" + : "config.needsofnature.tooltip.require_male_female_breeding.disabled_animation_breeding" + ); + } + } + + private Text toggleWithValue(boolean enabled) { + return Text.translatable(enabled ? "options.on" : "options.off"); + } + } +} diff --git a/src/main/java/com/nonid/integration/NonPlayerProfilesScreen.java b/src/main/java/com/nonid/integration/NonPlayerProfilesScreen.java new file mode 100644 index 0000000..f9f466a --- /dev/null +++ b/src/main/java/com/nonid/integration/NonPlayerProfilesScreen.java @@ -0,0 +1,367 @@ +package com.nonid.integration; + +import com.nonid.NonConfig; +import com.nonid.client.NonPlayerInteractionClient; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.gui.widget.ElementListWidget.Entry; +import net.minecraft.text.Text; + +final class NonPlayerProfilesScreen extends Screen { + private static final String[] MODES = new String[]{"deny_all", "requests_only", "requests_and_attacks", "auto_accept"}; + private final Screen parent; + private ButtonWidget defaultModeButton; + private ButtonWidget typedModeButton; + private TextFieldWidget playerNameField; + private NonPlayerProfilesScreen.ProfileList profileList; + private String typedMode = "requests_and_attacks"; + private int renderedRevision = -1; + + NonPlayerProfilesScreen(Screen parent) { + super(Text.translatable("gui.needsofnature.player_profiles.title")); + this.parent = parent; + } + + protected void init() { + int centerX = this.width / 2; + int top = 34; + this.defaultModeButton = ButtonWidget.builder(Text.literal(""), ignored -> { + NonPlayerInteractionClient.ProfileSnapshot snapshot = NonPlayerInteractionClient.profileSnapshot(); + String next = nextAllowedMode(snapshot.default_mode(), snapshot.allowed_mask()); + NonPlayerInteractionClient.setDefaultProfile(next); + }).dimensions(centerX + 20, top, 180, 20).build(); + this.addDrawableChild(this.defaultModeButton); + this.playerNameField = new TextFieldWidget( + this.textRenderer, centerX - 220, top + 30, 185, 20, Text.translatable("gui.needsofnature.player_profiles.player_name") + ); + this.playerNameField.setMaxLength(64); + this.playerNameField.setPlaceholder(Text.translatable("gui.needsofnature.player_profiles.player_name_hint")); + this.addDrawableChild(this.playerNameField); + this.typedModeButton = ButtonWidget.builder(modeText(this.typedMode), ignored -> { + this.typedMode = nextAllowedMode(this.typedMode, NonPlayerInteractionClient.profileSnapshot().allowed_mask()); + this.typedModeButton.setMessage(modeText(this.typedMode)); + }).dimensions(centerX - 29, top + 30, 174, 20).build(); + this.addDrawableChild(this.typedModeButton); + this.addDrawableChild(ButtonWidget.builder(Text.translatable("gui.needsofnature.player_profiles.apply"), ignored -> { + String name = this.playerNameField.getText().trim(); + if (!name.isEmpty()) { + NonPlayerInteractionClient.setPlayerProfile(name, this.typedMode); + } + }).dimensions(centerX + 151, top + 30, 69, 20).build()); + int listTop = top + 60; + int bottom = 38; + this.profileList = new NonPlayerProfilesScreen.ProfileList( + this.client, + this.width, + Math.max(40, this.height - listTop - bottom), + listTop, + this.textRenderer, + this::setProfile, + NonPlayerInteractionClient::removePlayerProfile + ); + this.addDrawableChild(this.profileList); + if (NonModMenuScreens.canEditServerGameplaySettings()) { + this.addDrawableChild( + ButtonWidget.builder( + Text.translatable("gui.needsofnature.player_profiles.allowed_profiles"), + ignored -> MinecraftClient.getInstance() + .setScreen(new NonPlayerProfilesScreen.AllowedProfilesScreen(this, NonModMenuScreens.serverConfigForScreen())) + ) + .dimensions(centerX - 220, this.height - 28, 135, 20) + .build() + ); + } + + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.done"), ignored -> this.close()).dimensions(centerX - 75, this.height - 28, 150, 20).build() + ); + NonPlayerInteractionClient.requestProfiles(); + this.refreshFromSnapshot(true); + } + + public void tick() { + super.tick(); + this.refreshFromSnapshot(false); + } + + private void refreshFromSnapshot(boolean force) { + int revision = NonPlayerInteractionClient.profileRevision(); + if (force || revision != this.renderedRevision) { + this.renderedRevision = revision; + NonPlayerInteractionClient.ProfileSnapshot snapshot = NonPlayerInteractionClient.profileSnapshot(); + if (this.defaultModeButton != null) { + this.defaultModeButton.setMessage(modeText(snapshot.default_mode())); + } + + if (!isAllowed(this.typedMode, snapshot.allowed_mask())) { + this.typedMode = firstAllowedMode(snapshot.allowed_mask()); + if (this.typedModeButton != null) { + this.typedModeButton.setMessage(modeText(this.typedMode)); + } + } + + if (this.profileList != null) { + this.profileList.setSnapshot(snapshot); + } + } + } + + private void setProfile(NonPlayerProfilesScreen.ProfileRow row) { + String next = nextAllowedMode(row.mode(), NonPlayerInteractionClient.profileSnapshot().allowed_mask()); + NonPlayerInteractionClient.setPlayerProfile(row.uuid().toString(), next); + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 14, -1); + context.drawTextWithShadow(this.textRenderer, Text.translatable("gui.needsofnature.player_profiles.default"), this.width / 2 - 220, 40, -3092272); + } + + public void close() { + MinecraftClient.getInstance().setScreen(this.parent); + } + + private static Text modeText(String mode) { + return Text.translatable("gui.needsofnature.player_profiles.mode." + normalizeMode(mode)); + } + + private static String normalizeMode(String mode) { + for (String candidate : MODES) { + if (candidate.equals(mode)) { + return candidate; + } + } + + return "requests_and_attacks"; + } + + private static String nextAllowedMode(String current, int allowedMask) { + String normalized = normalizeMode(current); + int currentIndex = 0; + + for (int index = 0; index < MODES.length; index++) { + if (MODES[index].equals(normalized)) { + currentIndex = index; + } + } + + for (int offset = 1; offset <= MODES.length; offset++) { + String candidate = MODES[(currentIndex + offset) % MODES.length]; + if (isAllowed(candidate, allowedMask)) { + return candidate; + } + } + + return "requests_and_attacks"; + } + + private static String firstAllowedMode(int allowedMask) { + for (String mode : MODES) { + if (isAllowed(mode, allowedMask)) { + return mode; + } + } + + return "requests_and_attacks"; + } + + private static boolean isAllowed(String mode, int allowedMask) { + int index = List.of(MODES).indexOf(normalizeMode(mode)); + return index >= 0 && (allowedMask & 1 << index) != 0; + } + + private static final class AllowedProfilesScreen extends Screen { + private final Screen parent; + private final NonConfig config; + private final List modeButtons = new ArrayList<>(); + private int mask; + + private AllowedProfilesScreen(Screen parent, NonConfig config) { + super(Text.translatable("gui.needsofnature.player_profiles.allowed_profiles")); + this.parent = parent; + this.config = config; + this.mask = config.getAllowedPlayerInteractionProfilesMask(); + } + + protected void init() { + this.modeButtons.clear(); + int width = Math.min(340, this.width - 40); + int startY = Math.max(42, this.height / 2 - 62); + + for (int index = 0; index < NonPlayerProfilesScreen.MODES.length; index++) { + int bit = 1 << index; + String mode = NonPlayerProfilesScreen.MODES[index]; + ButtonWidget button = ButtonWidget.builder(Text.literal(""), ignored -> this.toggle(bit)) + .dimensions(this.width / 2 - width / 2, startY + index * 24, width, 20) + .build(); + this.modeButtons.add(button); + this.addDrawableChild(button); + } + + this.addDrawableChild( + ButtonWidget.builder(Text.translatable("gui.done"), ignored -> this.close()).dimensions(this.width / 2 - 75, this.height - 28, 150, 20).build() + ); + this.updateButtons(); + } + + private void toggle(int bit) { + int next = this.mask ^ bit; + if ((next & 15) != 0) { + this.mask = next & 15; + this.updateButtons(); + } + } + + private void updateButtons() { + for (int index = 0; index < this.modeButtons.size(); index++) { + boolean enabled = (this.mask & 1 << index) != 0; + this.modeButtons + .get(index) + .setMessage( + Text.translatable( + enabled ? "gui.needsofnature.player_profiles.allowed" : "gui.needsofnature.player_profiles.disallowed", + new Object[]{NonPlayerProfilesScreen.modeText(NonPlayerProfilesScreen.MODES[index])} + ) + ); + } + } + + public void render(DrawContext context, int mouseX, int mouseY, float deltaTicks) { + super.render(context, mouseX, mouseY, deltaTicks); + context.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, -1); + context.drawCenteredTextWithShadow( + this.textRenderer, Text.translatable("gui.needsofnature.player_profiles.allowed_profiles_hint"), this.width / 2, 29, -5592406 + ); + } + + public void close() { + this.config.setAllowedPlayerInteractionProfilesMask(this.mask); + NonModMenuScreens.saveServerConfig(this.config); + MinecraftClient.getInstance().setScreen(this.parent); + } + } + + private static final class ProfileList extends ElementListWidget { + private final TextRenderer textRenderer; + private final Consumer onCycle; + private final Consumer onRemove; + + private ProfileList( + MinecraftClient client, + int width, + int height, + int top, + TextRenderer textRenderer, + Consumer onCycle, + Consumer onRemove + ) { + super(client, width, height, top, 34); + this.textRenderer = textRenderer; + this.onCycle = onCycle; + this.onRemove = onRemove; + this.centerListVertically = false; + } + + private void setSnapshot(NonPlayerInteractionClient.ProfileSnapshot snapshot) { + this.clearEntries(); + Map overrides = new HashMap<>(); + + for (NonPlayerInteractionClient.ProfileEntry override : snapshot.overrides()) { + overrides.put(override.uuid(), override); + } + + for (NonPlayerInteractionClient.KnownPlayerEntry known : snapshot.known_players()) { + try { + UUID uuid = UUID.fromString(known.uuid()); + NonPlayerInteractionClient.ProfileEntry override = overrides.get(known.uuid()); + String mode = override == null ? snapshot.default_mode() : override.mode(); + this.addEntry( + new NonPlayerProfilesScreen.ProfileList.RowEntry( + new NonPlayerProfilesScreen.ProfileRow(uuid, known.name(), mode, override != null), this.textRenderer, this.onCycle, this.onRemove + ) + ); + } catch (IllegalArgumentException var8) { + } + } + } + + public int getRowWidth() { + return Math.min(440, Math.max(260, this.getWidth() - 30)); + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + protected int getScrollbarX() { + return this.getWidth() - 8; + } + + private static final class RowEntry extends Entry { + private final NonPlayerProfilesScreen.ProfileRow row; + private final TextRenderer textRenderer; + private final ButtonWidget modeButton; + private final ButtonWidget removeButton; + private final List widgets; + + private RowEntry( + NonPlayerProfilesScreen.ProfileRow row, TextRenderer textRenderer, Consumer onCycle, Consumer onRemove + ) { + this.row = row; + this.textRenderer = textRenderer; + this.modeButton = ButtonWidget.builder(NonPlayerProfilesScreen.modeText(row.mode()), ignored -> onCycle.accept(row)) + .dimensions(0, 0, 170, 20) + .build(); + this.removeButton = ButtonWidget.builder(Text.literal("x"), ignored -> onRemove.accept(row.uuid().toString())).dimensions(0, 0, 20, 20).build(); + this.removeButton.active = row.overridden(); + this.widgets = List.of(this.modeButton, this.removeButton); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int x = this.getContentX() + 3; + int y = this.getContentY() + 4; + NonPlayerInteractionClient.drawPlayerFace(context, this.row.uuid(), x, y, 24); + int right = this.getContentX() + this.getContentWidth() - 3; + this.removeButton.setX(right - 20); + this.removeButton.setY(y + 2); + this.modeButton.setX(right - 196); + this.modeButton.setY(y + 2); + this.modeButton.render(context, mouseX, mouseY, deltaTicks); + this.removeButton.render(context, mouseX, mouseY, deltaTicks); + int textWidth = Math.max(30, this.modeButton.getX() - (x + 31) - 6); + context.drawTextWithShadow( + this.textRenderer, this.textRenderer.trimToWidth(this.row.name(), textWidth), x + 31, y + 8, this.row.overridden() ? -1 : -5592406 + ); + } + + public List selectableChildren() { + return this.widgets; + } + + public List children() { + return this.widgets; + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + } + } + + private record ProfileRow(UUID uuid, String name, String mode, boolean overridden) { + } +} diff --git a/src/main/java/com/nonid/integration/NonWildfireGenderConfigCompat.java b/src/main/java/com/nonid/integration/NonWildfireGenderConfigCompat.java new file mode 100644 index 0000000..db9f41f --- /dev/null +++ b/src/main/java/com/nonid/integration/NonWildfireGenderConfigCompat.java @@ -0,0 +1,52 @@ +package com.nonid.integration; + +import com.nonid.NeedsOfNature; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.fabricmc.loader.api.FabricLoader; + +public final class NonWildfireGenderConfigCompat { + private static final String MOD_ID = "wildfire_gender"; + private static final float NATIVE_BREAST_SIZE_MAX = 0.8F; + private static boolean initialized; + private static float supportedBreastSizeMax = 0.8F; + + private NonWildfireGenderConfigCompat() { + } + + public static synchronized void initialize() { + if (!initialized) { + initialized = true; + if (FabricLoader.getInstance().isModLoaded("wildfire_gender")) { + try { + Class configurationClass = Class.forName("com.wildfire.main.config.Configuration"); + Object breastSizeKey = configurationClass.getField("BUST_SIZE").get(null); + Method getMaxInclusive = breastSizeKey.getClass().getMethod("getMaxInclusive"); + float currentMax = ((Number)getMaxInclusive.invoke(breastSizeKey)).floatValue(); + float targetMax = Math.max(currentMax, 1.6F); + if (currentMax < targetMax) { + Class numberConfigKeyClass = Class.forName("com.wildfire.main.config.types.NumberConfigKey"); + Field maxInclusive = numberConfigKeyClass.getDeclaredField("maxInclusive"); + maxInclusive.setAccessible(true); + maxInclusive.set(breastSizeKey, targetMax); + } + + float appliedMax = ((Number)getMaxInclusive.invoke(breastSizeKey)).floatValue(); + if (appliedMax < targetMax) { + throw new IllegalStateException("Female Gender Mod breast-size maximum remained at " + appliedMax); + } + + supportedBreastSizeMax = appliedMax; + } catch (ReflectiveOperationException | RuntimeException e) { + NeedsOfNature.LOGGER + .warn("[NoN] Could not extend Female Gender Mod's breast-size range. Values above 100% will be clamped for compatibility.", e); + } + } + } + } + + public static float clampBreastSize(float breastSize) { + initialize(); + return Math.clamp(breastSize, 0.0F, supportedBreastSizeMax); + } +} diff --git a/src/main/java/com/nonid/integration/PercentSliderWidget.java b/src/main/java/com/nonid/integration/PercentSliderWidget.java new file mode 100644 index 0000000..567226d --- /dev/null +++ b/src/main/java/com/nonid/integration/PercentSliderWidget.java @@ -0,0 +1,42 @@ +package com.nonid.integration; + +import java.util.function.IntConsumer; +import net.minecraft.client.gui.widget.SliderWidget; +import net.minecraft.text.Text; + +final class PercentSliderWidget extends SliderWidget { + private final IntConsumer listener; + private int percent; + + PercentSliderWidget(int x, int y, int width, int height, int initialPercent, IntConsumer listener) { + super(x, y, width, height, Text.literal(""), clampPercent(initialPercent) / 200.0); + this.listener = listener; + this.percent = clampPercent(initialPercent); + this.updateMessage(); + } + + void setPercent(int value) { + this.percent = clampPercent(value); + this.value = this.percent / 200.0; + this.updateMessage(); + if (this.listener != null) { + this.listener.accept(this.percent); + } + } + + protected void updateMessage() { + this.percent = clampPercent((int)Math.round(this.value * 200.0)); + this.setMessage(Text.translatable("config.needsofnature.action_sound_volume.value", new Object[]{this.percent})); + } + + protected void applyValue() { + this.percent = clampPercent((int)Math.round(this.value * 200.0)); + if (this.listener != null) { + this.listener.accept(this.percent); + } + } + + private static int clampPercent(int value) { + return Math.max(0, Math.min(200, value)); + } +} diff --git a/src/main/java/com/nonid/integration/SettingsList.java b/src/main/java/com/nonid/integration/SettingsList.java new file mode 100644 index 0000000..7444d83 --- /dev/null +++ b/src/main/java/com/nonid/integration/SettingsList.java @@ -0,0 +1,202 @@ +package com.nonid.integration; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.TextWidget; +import net.minecraft.client.gui.widget.ElementListWidget.Entry; +import net.minecraft.text.Text; + +final class SettingsList extends ElementListWidget { + SettingsList(MinecraftClient client, int width, int height, int top) { + super(client, width, height, top, 24); + this.centerListVertically = false; + } + + void addEntryRow(SettingsList.RowEntry entry) { + super.addEntry(entry); + } + + public int getRowWidth() { + return 440; + } + + public int getRowLeft() { + return (this.getWidth() - this.getRowWidth()) / 2; + } + + static final class RowEntry extends Entry { + private final TextRenderer textRenderer; + private final Text label; + private final TextWidget labelWidget; + private final ClickableWidget primary; + private final ClickableWidget reset; + private final SettingsList.RowEntry.RowType type; + private final List widgets; + + private RowEntry( + TextRenderer textRenderer, Text label, ClickableWidget primary, ClickableWidget reset, SettingsList.RowEntry.RowType type, Tooltip tooltip + ) { + this.textRenderer = textRenderer; + this.label = label; + this.labelWidget = label == null ? null : new TextWidget(0, 0, 0, 20, label, textRenderer); + this.primary = primary; + this.reset = reset; + this.type = type; + this.widgets = new ArrayList<>(2); + if (primary != null) { + this.widgets.add(primary); + } + + if (reset != null) { + this.widgets.add(reset); + } + + if (tooltip != null) { + if (this.labelWidget != null) { + NonModMenuScreens.setTooltip(this.labelWidget, tooltip); + } + + if (this.primary != null) { + NonModMenuScreens.setTooltip(this.primary, tooltip); + } + } + } + + static SettingsList.RowEntry labeledField(TextRenderer textRenderer, Text label, ClickableWidget field, ClickableWidget reset, Tooltip tooltip) { + return new SettingsList.RowEntry(textRenderer, label, field, reset, SettingsList.RowEntry.RowType.LABELED_FIELD, tooltip); + } + + static SettingsList.RowEntry groupedField(TextRenderer textRenderer, Text label, Tooltip tooltip, ClickableWidget... fields) { + SettingsList.RowEntry entry = new SettingsList.RowEntry(textRenderer, label, null, null, SettingsList.RowEntry.RowType.GROUPED_FIELD, tooltip); + if (fields != null) { + for (ClickableWidget field : fields) { + if (field != null) { + entry.widgets.add(field); + } + } + } + + return entry; + } + + static SettingsList.RowEntry buttonWithReset(TextRenderer textRenderer, ClickableWidget button, Tooltip tooltip) { + return new SettingsList.RowEntry(textRenderer, null, button, null, SettingsList.RowEntry.RowType.BUTTON_RESET, tooltip); + } + + static SettingsList.RowEntry sectionHeader(TextRenderer textRenderer, Text label) { + return new SettingsList.RowEntry(textRenderer, label, null, null, SettingsList.RowEntry.RowType.SECTION_HEADER, null); + } + + public void render(DrawContext context, int mouseX, int mouseY, boolean hovered, float deltaTicks) { + int rowX = this.getContentX(); + int rowY = this.getContentY(); + int rowWidth = this.getContentWidth(); + int widgetY = rowY + 2; + if (this.type == SettingsList.RowEntry.RowType.SECTION_HEADER) { + if (this.label != null) { + int bandY = rowY + 3; + int bandH = 18; + int centerX = rowX + rowWidth / 2; + int textY = rowY + 8; + int lineY = rowY + 12; + int textW = this.textRenderer.getWidth(this.label); + int leftEnd = centerX - textW / 2 - 10; + int rightStart = centerX + textW / 2 + 10; + context.fill(rowX + 2, bandY, rowX + rowWidth - 2, bandY + bandH, 855638016); + context.fill(rowX + 8, lineY, Math.max(rowX + 8, leftEnd), lineY + 1, 1717986918); + context.fill(Math.min(rowX + rowWidth - 8, rightStart), lineY, rowX + rowWidth - 8, lineY + 1, 1717986918); + context.drawCenteredTextWithShadow(this.textRenderer, this.label, centerX, textY, -3158065); + } + } else { + if (this.type == SettingsList.RowEntry.RowType.GROUPED_FIELD) { + int totalW = 0; + + for (ClickableWidget widget : this.widgets) { + totalW += widget.getWidth(); + } + + totalW += Math.max(0, this.widgets.size() - 1) * 4; + int x = rowX + rowWidth - totalW; + if (this.labelWidget != null) { + int labelWidth = Math.max(20, x - rowX - 8); + this.labelWidget.setX(rowX + 4); + this.labelWidget.setY(rowY + 6); + this.labelWidget.setWidth(labelWidth); + } + + for (ClickableWidget widget : this.widgets) { + widget.setX(x); + widget.setY(widgetY); + x += widget.getWidth() + 4; + } + } else if (this.primary != null) { + if (this.type == SettingsList.RowEntry.RowType.LABELED_FIELD) { + int resetW = this.reset == null ? 0 : this.reset.getWidth(); + int fieldW = this.primary.getWidth(); + int resetX = rowX + rowWidth - resetW; + int fieldX = resetX - 6 - fieldW; + this.primary.setX(fieldX); + this.primary.setY(widgetY); + if (this.reset != null) { + this.reset.setX(resetX); + this.reset.setY(widgetY); + } + + if (this.labelWidget != null) { + int labelWidth = Math.max(20, fieldX - rowX - 8); + this.labelWidget.setX(rowX + 4); + this.labelWidget.setY(rowY + 6); + this.labelWidget.setWidth(labelWidth); + } + } else { + int resetW = this.reset == null ? 0 : this.reset.getWidth(); + int buttonW = Math.max(20, rowWidth - resetW - 6); + this.primary.setX(rowX); + this.primary.setY(widgetY); + this.primary.setWidth(buttonW); + if (this.reset != null) { + this.reset.setX(rowX + rowWidth - resetW); + this.reset.setY(widgetY); + } + } + } + + if (this.labelWidget != null) { + this.labelWidget.render(context, mouseX, mouseY, deltaTicks); + } + + for (ClickableWidget widget : this.widgets) { + widget.render(context, mouseX, mouseY, deltaTicks); + } + } + } + + public List selectableChildren() { + return List.copyOf(this.widgets); + } + + public void forEachChild(Consumer consumer) { + this.widgets.forEach(consumer); + } + + public List children() { + return List.copyOf(this.widgets); + } + + private enum RowType { + LABELED_FIELD, + GROUPED_FIELD, + BUTTON_RESET, + SECTION_HEADER; + } + } +} diff --git a/src/main/java/com/nonid/integration/jade/NonJadePlugin.java b/src/main/java/com/nonid/integration/jade/NonJadePlugin.java new file mode 100644 index 0000000..85ee228 --- /dev/null +++ b/src/main/java/com/nonid/integration/jade/NonJadePlugin.java @@ -0,0 +1,58 @@ +package com.nonid.integration.jade; + +import com.nonid.GenderHolder; +import com.nonid.NeedsOfNature; +import net.minecraft.entity.Entity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import snownee.jade.api.EntityAccessor; +import snownee.jade.api.IEntityComponentProvider; +import snownee.jade.api.ITooltip; +import snownee.jade.api.IWailaClientRegistration; +import snownee.jade.api.IWailaPlugin; +import snownee.jade.api.WailaPlugin; +import snownee.jade.api.config.IPluginConfig; + +@WailaPlugin +public final class NonJadePlugin implements IWailaPlugin { + private static final Identifier GENDER = NeedsOfNature.id("gender"); + + public void registerClient(IWailaClientRegistration registration) { + registration.addConfig(GENDER, true); + registration.registerEntityComponent(NonJadePlugin.GenderProvider.INSTANCE, MobEntity.class); + registration.markAsClientFeature(GENDER); + } + + private enum GenderProvider implements IEntityComponentProvider { + INSTANCE; + + public void appendTooltip(ITooltip tooltip, EntityAccessor accessor, IPluginConfig config) { + String symbol = genderSymbol(accessor.getEntity()); + if (!symbol.isEmpty()) { + tooltip.append(Text.literal(" " + symbol), NonJadePlugin.GENDER); + } + } + + public Identifier getUid() { + return NonJadePlugin.GENDER; + } + + public int getDefaultPriority() { + return -10099; + } + + private static String genderSymbol(Entity entity) { + if (entity instanceof GenderHolder holder) { + return switch (holder.getGenderMask() & 3) { + case 1 -> "♂"; + case 2 -> "♀"; + case 3 -> "♂♀"; + default -> ""; + }; + } else { + return ""; + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/AnimationEngine.java b/src/main/java/com/nonid/internal/animation/AnimationEngine.java new file mode 100644 index 0000000..3641040 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/AnimationEngine.java @@ -0,0 +1,326 @@ +package com.nonid.internal.animation; + +import com.nonid.NeedsOfNature; +import com.nonid.NonDebugChatCategory; +import com.nonid.NonDebugChatMode; +import com.nonid.NonDiagnostics; +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.api.NonAnimationApiBridge; +import com.nonid.internal.animation.data.AnimationCueIndex; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AdjustAnimationSpeedC2SPayload; +import com.nonid.internal.animation.network.AdvanceAnimationStageS2CPayload; +import com.nonid.internal.animation.network.AnimationSpeedUpdateS2CPayload; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.network.DebugAdvanceStageC2SPayload; +import com.nonid.internal.animation.network.DebugChatPreferenceC2SPayload; +import com.nonid.internal.animation.network.DebugJoinAnimationC2SPayload; +import com.nonid.internal.animation.network.DebugStartAnimationC2SPayload; +import com.nonid.internal.animation.network.DebugStopAllAnimationsC2SPayload; +import com.nonid.internal.animation.network.DebugStopAnimationC2SPayload; +import com.nonid.internal.animation.network.StartAnimationS2CPayload; +import com.nonid.internal.animation.network.StopAllAnimationsS2CPayload; +import com.nonid.internal.animation.network.StopAnimationS2CPayload; +import com.nonid.internal.animation.server.AnimationStartContext; +import com.nonid.internal.animation.server.ServerAnimationController; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStarted; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Join; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.server.PlayerConfigEntry; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.slf4j.Logger; + +public final class AnimationEngine { + public static final String PACK_NAMESPACE = "animationframework"; + public static final String RUNTIME_NAMESPACE = "needsofnature"; + public static final Logger LOGGER = NeedsOfNature.LOGGER; + + private AnimationEngine() { + } + + public static Identifier packId(String path) { + return Identifier.of("animationframework", path); + } + + public static Identifier runtimeId(String path) { + return Identifier.of("needsofnature", "animation/" + path); + } + + public static void initialize() { + PayloadTypeRegistry.playC2S().register(DebugStartAnimationC2SPayload.ID, DebugStartAnimationC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DebugStopAllAnimationsC2SPayload.ID, DebugStopAllAnimationsC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DebugStopAnimationC2SPayload.ID, DebugStopAnimationC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DebugChatPreferenceC2SPayload.ID, DebugChatPreferenceC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DebugJoinAnimationC2SPayload.ID, DebugJoinAnimationC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(DebugAdvanceStageC2SPayload.ID, DebugAdvanceStageC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(AdjustAnimationSpeedC2SPayload.ID, AdjustAnimationSpeedC2SPayload.CODEC); + PayloadTypeRegistry.playS2C().register(StartAnimationS2CPayload.ID, StartAnimationS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AdvanceAnimationStageS2CPayload.ID, AdvanceAnimationStageS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(StopAllAnimationsS2CPayload.ID, StopAllAnimationsS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(StopAnimationS2CPayload.ID, StopAnimationS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AnimationSpeedUpdateS2CPayload.ID, AnimationSpeedUpdateS2CPayload.CODEC); + NonAnimationApiBridge.initialize(); + AnimationDefinitions.registerReloadListener(); + AnimationCueIndex.registerReloadListener(); + ServerAnimationController.init(); + registerStartupResourceReload(); + ServerPlayNetworking.registerGlobalReceiver(DebugStartAnimationC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "start")) { + NonAnimationDamageBehavior behavior = NonAnimationDamageBehavior.fromId(payload.damageBehaviorId(), NonAnimationDamageBehavior.STOP_ON_DAMAGE); + handleDebugStartRequest(player, payload.actorEntityIds(), behavior, payload.ignoreAttackers(), payload.anchorEntityId()); + } + })); + ServerPlayNetworking.registerGlobalReceiver(DebugStopAllAnimationsC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "stop_all")) { + if (player.getEntityWorld() instanceof ServerWorld world) { + AnimationService.stopAll(world); + sendDebugChat(player, NonDebugChatCategory.ALWAYS, Text.translatable("debug.needsofnature.animation.stop_all_broadcast_queued"), true); + } + } + })); + ServerPlayNetworking.registerGlobalReceiver(DebugStopAnimationC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "stop_instance")) { + ServerAnimationController.stopInstanceAndBroadcast(player, payload.instanceId()); + } + })); + ServerPlayNetworking.registerGlobalReceiver(DebugChatPreferenceC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + UUID uuid = context.player().getUuid(); + NonDebugChatMode mode = NonDebugChatMode.fromId(payload.modeId(), NonDebugChatMode.SETUP_ERRORS); + NonDiagnostics.setDebugChatPreference(uuid, mode); + })); + ServerPlayNetworking.registerGlobalReceiver(DebugJoinAnimationC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "join")) { + handleDebugJoinRequest(player, payload.instanceId(), payload.actorEntityIds()); + } + })); + ServerPlayNetworking.registerGlobalReceiver(DebugAdvanceStageC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "advance_stage")) { + if (player.getEntityWorld() instanceof ServerWorld world) { + ServerAnimationController.advanceStage(world, payload.instanceId()); + } + } + })); + ServerPlayNetworking.registerGlobalReceiver(AdjustAnimationSpeedC2SPayload.ID, (payload, context) -> context.server().execute(() -> { + ServerPlayerEntity player = context.player(); + if (requireDebugControlPermission(player, "adjust_speed")) { + if (player.getEntityWorld() instanceof ServerWorld world) { + ServerAnimationController.adjustSpeed(world, payload.instanceId(), payload.multiplier(), player); + } + } + })); + ServerPlayConnectionEvents.JOIN + .register((Join)(handler, sender, server) -> server.execute(() -> ServerAnimationController.sendActiveInstancesToPlayer(handler.player))); + LOGGER.info("[NoN] Animation engine initialized"); + } + + private static void registerStartupResourceReload() { + ServerLifecycleEvents.SERVER_STARTED.register((ServerStarted)server -> server.execute(() -> { + try { + LOGGER.info("[NoN] Reloading startup resources so generated and default packs are active."); + server.reloadResources(server.getDataPackManager().getEnabledIds()).exceptionally(throwable -> { + LOGGER.warn("[NoN] Startup resource reload failed.", throwable); + return null; + }); + } catch (RuntimeException e) { + LOGGER.warn("[NoN] Failed to schedule startup resource reload.", e); + } + })); + } + + public static void logSetupWarning(String template, Object... args) { + NonDiagnostics.logSetupWarning(template, args); + } + + public static void logRuntime(String template, Object... args) { + NonDiagnostics.logRuntime(template, args); + } + + public static void logSetupError(String template, Object... args) { + NonDiagnostics.logSetupError(template, args); + } + + public static void queueSetupChat(String message) { + NonDiagnostics.queueSetupChat(message); + } + + public static String formatLogTemplate(String template, Object... args) { + return NonDiagnostics.formatLogTemplate(template, args); + } + + public static boolean shouldSendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category) { + return NonDiagnostics.shouldSendDebugChat(player, category); + } + + public static boolean shouldSendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, boolean force) { + return NonDiagnostics.shouldSendDebugChat(player, category, force); + } + + public static void sendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, String message, boolean force) { + sendDebugChat(player, category, Text.literal(message), force); + } + + public static void sendDebugChat(ServerPlayerEntity player, NonDebugChatCategory category, Text message, boolean force) { + NonDiagnostics.sendDebugChat(player, category, message, force); + } + + private static boolean requireDebugControlPermission(ServerPlayerEntity player, String action) { + if (player != null + && player.getEntityWorld() instanceof ServerWorld world + && world.getServer().getPlayerManager().isOperator(new PlayerConfigEntry(player.getGameProfile()))) { + return true; + } + + if (player != null) { + LOGGER.warn("Denied animation debug control '{}' from non-OP player {}", action, player.getName().getString()); + sendDebugChat(player, NonDebugChatCategory.WARNING, Text.literal("[NoN 动画] 调试控制需要管理员权限。"), true); + } + + return false; + } + + private static void handleDebugStartRequest( + ServerPlayerEntity player, List requestedActorIds, NonAnimationDamageBehavior damageBehavior, boolean ignoreAttackers, int anchorEntityId + ) { + if (!requestedActorIds.isEmpty()) { + List resolved = new ArrayList<>(); + Entity anchorEntity = null; + + for (int entityId : requestedActorIds) { + Entity entity = player.getEntityWorld().getEntityById(entityId); + if (entity instanceof LivingEntity) { + resolved.add(entity); + if (anchorEntity == null && entityId == anchorEntityId) { + anchorEntity = entity; + } + } + } + + if (!resolved.isEmpty()) { + resolved.sort(Comparator.comparingInt(Entity::getId)); + NonAnimationDamageBehavior safeBehavior = damageBehavior == null ? NonAnimationDamageBehavior.STOP_ON_DAMAGE : damageBehavior; + UUID anchorUuid = anchorEntity != null ? anchorEntity.getUuid() : null; + ServerAnimationController.MatchedStartRequest start = ServerAnimationController.startEligibleMatchedNow( + player.getEntityWorld(), player, resolved, Set.of(), safeBehavior, ignoreAttackers, anchorUuid, AnimationStartContext.empty(), true + ); + String chosenStr = start == null ? "(none)" : start.animationId().toString(); + List actorKeys = start == null ? List.of() : start.actorKeys(); + String keysStr = actorKeys.isEmpty() ? "none" : actorKeys.toString(); + MutableText msg = Text.translatable("debug.needsofnature.animation.server_eval", new Object[]{resolved.size(), chosenStr, keysStr}); + sendDebugChat(player, NonDebugChatCategory.ALWAYS, msg, true); + if (start != null) { + logRuntime("Debug request: player={} chosen={} actors={}", player.getName().getString(), start.animationId(), resolved.size()); + } + } + } + } + + private static void handleDebugJoinRequest(ServerPlayerEntity player, UUID instanceId, List requestedActorIds) { + if (player != null && requestedActorIds != null && !requestedActorIds.isEmpty()) { + if (player.getEntityWorld() instanceof ServerWorld world) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + if (snapshot == null) { + sendDebugChat(player, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.animation.join_failed_no_active_instance"), true); + } else { + AnimationStageInfo currentStage = ServerAnimationController.getCurrentStage(world, instanceId); + if (currentStage != null && currentStage.allowJoin()) { + List combined = new ArrayList<>(); + Set existingActorUuids = new HashSet<>(snapshot.actorUuids()); + + for (UUID actorUuid : snapshot.actorUuids()) { + Entity actor = world.getEntity(actorUuid); + if (actor != null) { + combined.add(actor); + } + } + + if (combined.size() != snapshot.actorUuids().size()) { + sendDebugChat( + player, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.animation.join_failed_no_active_instance"), true + ); + } else { + List additions = new ArrayList<>(); + + for (int id : requestedActorIds) { + Entity e = world.getEntityById(id); + if (e instanceof MobEntity && existingActorUuids.add(e.getUuid())) { + additions.add(e); + } + } + + if (!additions.isEmpty()) { + combined.addAll(additions); + combined.sort(Comparator.comparingInt(Entity::getId)); + AnimationDefinitions.MatchResult match = AnimationDefinitions.match(combined); + AnimationDefinitions.Definition definition = match.chosen(); + if (definition == null) { + sendDebugChat( + player, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.animation.join_failed_no_expanded_definition"), true + ); + } else { + List actorKeys = AnimationDefinitions.resolveActorKeys(definition, combined, world.getRandom()); + if (actorKeys != null && actorKeys.size() == combined.size()) { + AnimationStartContext replacementContext = snapshot.startContext().withJoinPredecessor(snapshot.animationId()); + if (!ServerAnimationController.stopInstance(world, instanceId, false)) { + sendDebugChat( + player, + NonDebugChatCategory.WARNING, + Text.translatable("debug.needsofnature.animation.join_failed_no_active_instance"), + true + ); + } else { + ServerAnimationController.startNowWithContext( + world, + player, + definition.id(), + combined, + actorKeys, + definition.stages(), + snapshot.damageBehavior(), + snapshot.ignoreAttackers(), + null, + replacementContext, + true, + false + ); + } + } else { + sendDebugChat( + player, + NonDebugChatCategory.WARNING, + Text.translatable("debug.needsofnature.animation.join_failed_no_expanded_definition"), + true + ); + } + } + } + } + } else { + sendDebugChat(player, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.animation.join_denied_stage_disallows"), true); + } + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/AnimationEngineClient.java b/src/main/java/com/nonid/internal/animation/AnimationEngineClient.java new file mode 100644 index 0000000..3be9e01 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/AnimationEngineClient.java @@ -0,0 +1,519 @@ +package com.nonid.internal.animation; + +import com.nonid.NeedsOfNature; +import com.nonid.NonDebugChatCategory; +import com.nonid.NonDiagnostics; +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.client.NonClientDiagnostics; +import com.nonid.internal.animation.client.camera.AnimatedCameraPoseTracker; +import com.nonid.internal.animation.client.camera.AnimationCameraZoom; +import com.nonid.internal.animation.client.diagnostics.AnimationAssetDiagnostics; +import com.nonid.internal.animation.client.render.gecko.BoneTextureOverrides; +import com.nonid.internal.animation.client.render.gecko.VanillaTextureResolver; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import com.nonid.internal.animation.client.sound.AnimationSoundEffects; +import com.nonid.internal.animation.network.AdjustAnimationSpeedC2SPayload; +import com.nonid.internal.animation.network.AdvanceAnimationStageS2CPayload; +import com.nonid.internal.animation.network.AnimationSpeedUpdateS2CPayload; +import com.nonid.internal.animation.network.DebugAdvanceStageC2SPayload; +import com.nonid.internal.animation.network.DebugChatPreferenceC2SPayload; +import com.nonid.internal.animation.network.DebugJoinAnimationC2SPayload; +import com.nonid.internal.animation.network.DebugStartAnimationC2SPayload; +import com.nonid.internal.animation.network.DebugStopAllAnimationsC2SPayload; +import com.nonid.internal.animation.network.DebugStopAnimationC2SPayload; +import com.nonid.internal.animation.network.StartAnimationS2CPayload; +import com.nonid.internal.animation.network.StopAllAnimationsS2CPayload; +import com.nonid.internal.animation.network.StopAnimationS2CPayload; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.Disconnect; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.Join; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.option.Perspective; +import net.minecraft.client.option.KeyBinding.Category; +import net.minecraft.client.util.InputUtil; +import net.minecraft.client.util.InputUtil.Type; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.registry.Registries; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; +import net.minecraft.util.hit.EntityHitResult; +import net.minecraft.util.math.Vec3d; + +public final class AnimationEngineClient { + private static final Category KEY_CATEGORY = Category.create(Identifier.of("needsofnature", "animation")); + private static final KeyBinding SELECT_ACTOR_KEY = KeyBindingHelper.registerKeyBinding(createUnboundDebugKey("key.needsofnature.animation.select_actor")); + private static final KeyBinding SELECT_SELF_KEY = KeyBindingHelper.registerKeyBinding(createUnboundDebugKey("key.needsofnature.animation.select_self")); + private static final KeyBinding START_DEBUG_KEY = KeyBindingHelper.registerKeyBinding( + createUnboundDebugKey("key.needsofnature.animation.start_debug_animation") + ); + private static final KeyBinding STOP_ALL_DEBUG_KEY = KeyBindingHelper.registerKeyBinding(createUnboundDebugKey("key.needsofnature.animation.stop_all_debug")); + private static final KeyBinding STOP_INSTANCE_DEBUG_KEY = KeyBindingHelper.registerKeyBinding( + createUnboundDebugKey("key.needsofnature.animation.stop_instance_debug") + ); + private static final KeyBinding NEXT_STAGE_DEBUG_KEY = KeyBindingHelper.registerKeyBinding( + createUnboundDebugKey("key.needsofnature.animation.next_stage_debug") + ); + private static final KeyBinding SPEED_UP_DEBUG_KEY = KeyBindingHelper.registerKeyBinding(createUnboundDebugKey("key.needsofnature.animation.speed_up_debug")); + private static final KeyBinding SPEED_DOWN_DEBUG_KEY = KeyBindingHelper.registerKeyBinding( + createUnboundDebugKey("key.needsofnature.animation.speed_down_debug") + ); + private static final IntArrayList SELECTED_ACTOR_IDS = new IntArrayList(); + private static UUID SELECTED_INSTANCE_ID = null; + private static boolean wasSelfAnimating = false; + private static boolean ownsCameraPerspective = false; + private static Perspective preAnimationPerspective = null; + private static final double PLAYER_ANCHOR_EPSILON_SQ = 4.0E-4; + + public static void sendClientDebugChat(NonDebugChatCategory category, Text message) { + NonClientDiagnostics.send(category, message, false); + } + + public static void sendClientSetupWarning(String message) { + NonClientDiagnostics.sendSetupWarning(message, false); + } + + private static KeyBinding createUnboundDebugKey(String translationKey) { + return new KeyBinding(translationKey, Type.KEYSYM, InputUtil.UNKNOWN_KEY.getCode(), KEY_CATEGORY); + } + + private AnimationEngineClient() { + } + + public static void initialize() { + VanillaTextureResolver.registerReloadListener(); + BoneTextureOverrides.registerReloadListener(); + AnimationSoundEffects.registerReloadListener(); + AnimationAssetDiagnostics.registerReloadListener(); + ClientPlayConnectionEvents.JOIN.register((Join)(handler, sender, client) -> sendDebugChatPreference()); + ClientPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, client) -> { + ClientAnimationRuntime.stopAllNow(); + AnimatedCameraPoseTracker.clear(); + AnimationCameraZoom.reset(); + resetCameraPerspective(client); + }); + ClientPlayNetworking.registerGlobalReceiver( + StartAnimationS2CPayload.ID, + (payload, context) -> context.client() + .execute( + () -> { + ClientAnimationRuntime.queueStart( + payload.animationId(), + payload.modelRootsByActor(), + payload.instanceId(), + payload.actorUuids(), + payload.actorKeys(), + payload.stages(), + payload.startTick(), + payload.stageIndex(), + payload.stageStartTick(), + payload.stageEndTick(), + payload.speed(), + payload.lockOrientation(), + payload.lockedYaw(), + payload.lockedHeadYaw(), + payload.lockedPitch(), + payload.cameraOrbitTarget() + ); + MinecraftClient client = context.client(); + PlayerEntity player = client.player; + if (player != null && NonClientDiagnostics.shouldSend(NonDebugChatCategory.INFO)) { + String keysStr = payload.actorKeys().isEmpty() ? "none" : payload.actorKeys().toString(); + player.sendMessage(tr("start_received", payload.animationId(), payload.actorUuids().size(), keysStr), false); + } + } + ) + ); + ClientPlayNetworking.registerGlobalReceiver(StopAllAnimationsS2CPayload.ID, (payload, context) -> context.client().execute(() -> { + ClientAnimationRuntime.queueStopAll(payload.stopTick()); + MinecraftClient client = context.client(); + PlayerEntity player = client.player; + if (player != null && NonClientDiagnostics.shouldSend(NonDebugChatCategory.INFO)) { + player.sendMessage(tr("stop_all_received"), false); + } + })); + ClientPlayNetworking.registerGlobalReceiver( + AdvanceAnimationStageS2CPayload.ID, + (payload, context) -> context.client() + .execute(() -> ClientAnimationRuntime.queueStageAdvance(payload.instanceId(), payload.advanceTick(), payload.stageEndTick(), payload.stageIndex())) + ); + ClientPlayNetworking.registerGlobalReceiver( + AnimationSpeedUpdateS2CPayload.ID, + (payload, context) -> context.client() + .execute(() -> ClientAnimationRuntime.queueSpeedUpdate(payload.instanceId(), payload.speed(), payload.stageStartTick(), payload.stageEndTick())) + ); + ClientPlayNetworking.registerGlobalReceiver(StopAnimationS2CPayload.ID, (payload, context) -> context.client().execute(() -> { + ClientAnimationRuntime.queueStop(payload.instanceId(), payload.stopTick()); + MinecraftClient client = context.client(); + PlayerEntity player = client.player; + if (player != null && NonClientDiagnostics.shouldSend(NonDebugChatCategory.INFO)) { + player.sendMessage(tr("stop_instance_received"), false); + } + })); + ClientTickEvents.END_CLIENT_TICK.register(AnimationEngineClient::onClientTick); + } + + public static void sendDebugChatPreference() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && ClientPlayNetworking.canSend(DebugChatPreferenceC2SPayload.ID)) { + ClientPlayNetworking.send(new DebugChatPreferenceC2SPayload(NeedsOfNature.getConfig().debugChatMode().id())); + } + } + + private static void onClientTick(MinecraftClient client) { + if (client.player != null && client.world != null) { + if (client.currentScreen == null) { + while (SELECT_ACTOR_KEY.wasPressed()) { + toggleTargetedActorSelection(client); + } + + while (SELECT_SELF_KEY.wasPressed()) { + toggleSelfSelection(client); + } + + while (START_DEBUG_KEY.wasPressed()) { + requestDebugStart(client); + } + + while (STOP_ALL_DEBUG_KEY.wasPressed()) { + requestStopAll(client); + } + + while (STOP_INSTANCE_DEBUG_KEY.wasPressed()) { + requestStopInstance(client); + } + + while (NEXT_STAGE_DEBUG_KEY.wasPressed()) { + requestNextStage(client); + } + + while (SPEED_UP_DEBUG_KEY.wasPressed()) { + requestSpeedAdjust(client, 1.1); + } + + while (SPEED_DOWN_DEBUG_KEY.wasPressed()) { + requestSpeedAdjust(client, 0.9); + } + } + + ClientAnimationRuntime.clientTick(client); + AnimationCameraZoom.clientTick(client); + AnimationAssetDiagnostics.validateLoadedDefinitionsOnce(); + AnimatedCameraPoseTracker.prune(client.world.getTime()); + handleCompletedStages(client); + syncCameraPerspective(client); + syncLocalPlayerToAnchor(client); + } + } + + private static void requestStopAll(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + if (!ClientPlayNetworking.canSend(DebugStopAllAnimationsC2SPayload.ID)) { + sendManualDebug(player, tr("server_cannot_receive_stop_all")); + ClientAnimationRuntime.stopAllNow(); + } else { + sendManualDebug(player, tr("stop_all_requested")); + ClientPlayNetworking.send(new DebugStopAllAnimationsC2SPayload()); + } + } + } + + private static void requestStopInstance(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + UUID self = player.getUuid(); + UUID instanceToStop = ClientAnimationRuntime.findLatestActiveInstanceContaining(self); + if (instanceToStop == null) { + instanceToStop = ClientAnimationRuntime.findLatestActiveInstance(); + } + + if (instanceToStop == null) { + sendManualDebug(player, tr("no_active_instance_to_stop")); + } else if (!ClientPlayNetworking.canSend(DebugStopAnimationC2SPayload.ID)) { + sendManualDebug(player, tr("server_cannot_receive_stop_instance")); + } else { + sendManualDebug(player, tr("stop_instance_requested")); + ClientPlayNetworking.send(new DebugStopAnimationC2SPayload(instanceToStop)); + } + } + } + + private static void toggleTargetedActorSelection(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + if (client.crosshairTarget instanceof EntityHitResult entityHit) { + Entity target = entityHit.getEntity(); + if (!(target instanceof LivingEntity)) { + sendManualDebug(player, tr("target_not_living")); + } else { + int id = target.getId(); + Identifier typeId = Registries.ENTITY_TYPE.getId(target.getType()); + UUID instId = ClientAnimationRuntime.findLatestActiveInstanceContaining(target.getUuid()); + if (instId != null) { + if (SELECTED_INSTANCE_ID != null && SELECTED_INSTANCE_ID.equals(instId)) { + SELECTED_INSTANCE_ID = null; + SELECTED_ACTOR_IDS.clear(); + sendManualDebug(player, tr("animation_deselected")); + } else { + SELECTED_INSTANCE_ID = instId; + SELECTED_ACTOR_IDS.clear(); + sendManualDebug(player, tr("animation_selected_add_actors_for_join")); + } + } else { + int existingIndex = indexOfSelectedActor(id); + if (existingIndex >= 0) { + SELECTED_ACTOR_IDS.removeInt(existingIndex); + sendManualDebug(player, tr("entity_deselected_actor_count", typeId.getPath(), SELECTED_ACTOR_IDS.size())); + } else { + SELECTED_ACTOR_IDS.add(id); + sendManualDebug(player, tr("entity_selected_actor_count", typeId.getPath(), SELECTED_ACTOR_IDS.size())); + } + } + } + } else { + sendManualDebug(player, tr("no_entity_targeted")); + } + } + } + + private static void toggleSelfSelection(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + int id = player.getId(); + int existingIndex = indexOfSelectedActor(id); + if (existingIndex >= 0) { + SELECTED_ACTOR_IDS.removeInt(existingIndex); + sendManualDebug(player, tr("self_deselected_actor_count", SELECTED_ACTOR_IDS.size())); + } else { + SELECTED_ACTOR_IDS.add(id); + sendManualDebug(player, tr("self_selected_actor_count", SELECTED_ACTOR_IDS.size())); + } + } + } + + private static void requestDebugStart(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + if (SELECTED_INSTANCE_ID == null && SELECTED_ACTOR_IDS.isEmpty()) { + sendManualDebug(player, tr("no_actors_selected")); + } else if (SELECTED_INSTANCE_ID != null) { + List actorIds = new ArrayList<>(SELECTED_ACTOR_IDS.size()); + + for (int i = 0; i < SELECTED_ACTOR_IDS.size(); i++) { + actorIds.add(SELECTED_ACTOR_IDS.getInt(i)); + } + + if (actorIds.isEmpty()) { + sendManualDebug(player, tr("no_extra_actors_for_join")); + } else if (!ClientPlayNetworking.canSend(DebugJoinAnimationC2SPayload.ID)) { + sendManualDebug(player, tr("server_cannot_receive_join")); + } else { + ClientPlayNetworking.send(new DebugJoinAnimationC2SPayload(SELECTED_INSTANCE_ID, actorIds)); + sendManualDebug(player, tr("join_requested_instance_extras", SELECTED_INSTANCE_ID, actorIds.size())); + SELECTED_INSTANCE_ID = null; + SELECTED_ACTOR_IDS.clear(); + } + } else if (!ClientPlayNetworking.canSend(DebugStartAnimationC2SPayload.ID)) { + sendManualDebug(player, tr("server_cannot_receive_debug_packets")); + } else { + List actorIds = new ArrayList<>(SELECTED_ACTOR_IDS.size()); + + for (int i = 0; i < SELECTED_ACTOR_IDS.size(); i++) { + actorIds.add(SELECTED_ACTOR_IDS.getInt(i)); + } + + sendManualDebug(player, tr("start_requested_actor_count", actorIds.size())); + ClientPlayNetworking.send(new DebugStartAnimationC2SPayload(actorIds, NonAnimationDamageBehavior.STOP_ON_DAMAGE.id(), false, -1)); + SELECTED_ACTOR_IDS.clear(); + } + } + } + + private static void requestNextStage(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null) { + UUID instanceToAdvance = ClientAnimationRuntime.findLatestActiveInstance(); + if (instanceToAdvance == null) { + sendManualDebug(player, tr("no_active_instance_to_advance")); + } else { + if (ClientPlayNetworking.canSend(DebugAdvanceStageC2SPayload.ID)) { + ClientPlayNetworking.send(new DebugAdvanceStageC2SPayload(instanceToAdvance)); + sendManualDebug(player, tr("next_stage_requested_server")); + } else { + ClientAnimationRuntime.requestStageAdvance(instanceToAdvance); + sendManualDebug(player, tr("next_stage_requested_local")); + } + } + } + } + + private static void requestSpeedAdjust(MinecraftClient client, double multiplier) { + PlayerEntity player = client.player; + if (player != null) { + UUID targetInstance = ClientAnimationRuntime.findLatestActiveInstanceContaining(player.getUuid()); + if (targetInstance == null) { + targetInstance = ClientAnimationRuntime.findLatestActiveInstance(); + } + + if (targetInstance == null) { + sendManualDebug(player, tr("no_active_instance_to_adjust_speed")); + } else { + if (ClientPlayNetworking.canSend(AdjustAnimationSpeedC2SPayload.ID)) { + ClientPlayNetworking.send(new AdjustAnimationSpeedC2SPayload(targetInstance, multiplier)); + double percent = (multiplier - 1.0) * 100.0; + String delta = (percent >= 0.0 ? "+" : "") + String.format(Locale.ROOT, "%.0f%%", percent); + sendManualDebug(player, tr("speed_change_requested", delta)); + } else { + sendManualDebug(player, tr("server_cannot_receive_speed")); + } + } + } + } + + private static void handleCompletedStages(MinecraftClient client) { + List completed = ClientAnimationRuntime.drainCompletedInstances(); + if (!completed.isEmpty()) { + PlayerEntity player = client.player; + boolean canSend = ClientPlayNetworking.canSend(DebugStopAnimationC2SPayload.ID); + + for (UUID instanceId : completed) { + if (canSend) { + ClientPlayNetworking.send(new DebugStopAnimationC2SPayload(instanceId)); + } else if (player != null && NonClientDiagnostics.shouldSend(NonDebugChatCategory.ERROR)) { + sendCategoryDebug(player, NonDebugChatCategory.ERROR, tr("stage_sequence_ended_server_stop_unavailable")); + } + } + } + } + + private static void syncCameraPerspective(MinecraftClient client) { + if (client.player != null && client.options != null && client.world != null) { + if (!NeedsOfNature.getConfig().autoSwitchThirdPersonOnAnimationStart()) { + restoreCameraPerspective(client); + } else { + UUID selfId = client.player.getUuid(); + boolean shouldThirdPerson = ClientAnimationRuntime.isActorPendingOrActive(selfId); + if (shouldThirdPerson) { + if (!wasSelfAnimating) { + preAnimationPerspective = client.options.getPerspective(); + ownsCameraPerspective = true; + if (client.options.getPerspective() != Perspective.THIRD_PERSON_BACK) { + client.options.setPerspective(Perspective.THIRD_PERSON_BACK); + } + } else if (ownsCameraPerspective && client.options.getPerspective() != Perspective.THIRD_PERSON_BACK) { + preAnimationPerspective = null; + ownsCameraPerspective = false; + } + + wasSelfAnimating = true; + } else { + if (ownsCameraPerspective || wasSelfAnimating) { + restoreCameraPerspective(client); + } + } + } + } + } + + private static void resetCameraPerspective(MinecraftClient client) { + if (client != null && client.options != null) { + restoreCameraPerspective(client); + } + } + + private static void restoreCameraPerspective(MinecraftClient client) { + if (client != null && client.options != null) { + if (ownsCameraPerspective && preAnimationPerspective != null) { + client.options.setPerspective(preAnimationPerspective); + } + + wasSelfAnimating = false; + ownsCameraPerspective = false; + preAnimationPerspective = null; + } + } + + private static void syncLocalPlayerToAnchor(MinecraftClient client) { + PlayerEntity player = client.player; + if (player != null && client.world != null) { + UUID selfId = player.getUuid(); + if (ClientAnimationRuntime.isActorPendingOrActive(selfId)) { + UUID anchorId = ClientAnimationRuntime.findAnchorUuidForActor(selfId); + if (anchorId == null) { + return; + } + + Entity anchor = client.world.getEntity(anchorId); + if (anchor == null) { + return; + } + + double dx = player.getX() - anchor.getX(); + double dy = player.getY() - anchor.getY(); + double dz = player.getZ() - anchor.getZ(); + if (dx * dx + dy * dy + dz * dz > 4.0E-4) { + player.setPos(anchor.getX(), anchor.getY(), anchor.getZ()); + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0.0; + } + } + } + } + + private static int indexOfSelectedActor(int value) { + for (int i = 0; i < SELECTED_ACTOR_IDS.size(); i++) { + if (SELECTED_ACTOR_IDS.getInt(i) == value) { + return i; + } + } + + return -1; + } + + private static Text tr(String key, Object... args) { + return Text.translatable("debug.needsofnature.animation." + key, sanitizeTranslatableArgs(args)); + } + + private static Object[] sanitizeTranslatableArgs(Object... args) { + if (args != null && args.length != 0) { + Object[] safe = new Object[args.length]; + + for (int i = 0; i < args.length; i++) { + Object arg = args[i]; + if (arg instanceof Identifier id) { + safe[i] = id.toString(); + } else { + safe[i] = !(arg instanceof Text) && arg != null ? String.valueOf(arg) : arg; + } + } + + return safe; + } else { + return new Object[0]; + } + } + + private static void sendManualDebug(PlayerEntity player, Text message) { + if (player != null) { + player.sendMessage(message.copy().formatted(Formatting.DARK_GRAY), false); + } + } + + private static void sendCategoryDebug(PlayerEntity player, NonDebugChatCategory category, Text message) { + if (player != null) { + player.sendMessage(message.copy().formatted(NonDiagnostics.chatColor(category)), false); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/api/AnimationLifecycleEvents.java b/src/main/java/com/nonid/internal/animation/api/AnimationLifecycleEvents.java new file mode 100644 index 0000000..9b0226b --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/api/AnimationLifecycleEvents.java @@ -0,0 +1,103 @@ +package com.nonid.internal.animation.api; + +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.server.AnimationStartContext; +import com.nonid.internal.animation.server.ServerAnimationController; +import java.util.List; +import java.util.UUID; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class AnimationLifecycleEvents { + public static final Event ALLOW_START = EventFactory.createArrayBacked( + AnimationLifecycleEvents.AllowStart.class, callbacks -> context -> { + for (AnimationLifecycleEvents.AllowStart callback : callbacks) { + if (!callback.allowStart(context)) { + return false; + } + } + + return true; + } + ); + public static final Event START = EventFactory.createArrayBacked( + AnimationLifecycleEvents.Start.class, callbacks -> context -> { + for (AnimationLifecycleEvents.Start callback : callbacks) { + callback.onStart(context); + } + } + ); + public static final Event STOP = EventFactory.createArrayBacked( + AnimationLifecycleEvents.Stop.class, callbacks -> context -> { + for (AnimationLifecycleEvents.Stop callback : callbacks) { + callback.onStop(context); + } + } + ); + public static final Event STAGE_ADVANCE = EventFactory.createArrayBacked( + AnimationLifecycleEvents.StageAdvance.class, callbacks -> context -> { + for (AnimationLifecycleEvents.StageAdvance callback : callbacks) { + callback.onStageAdvance(context); + } + } + ); + + private AnimationLifecycleEvents() { + } + + @FunctionalInterface + public interface AllowStart { + boolean allowStart(AnimationLifecycleEvents.AllowStartContext var1); + } + + public record AllowStartContext( + ServerWorld world, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + @Nullable ServerPlayerEntity requester, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext + ) { + public AllowStartContext { + actorUuids = actorUuids == null ? List.of() : List.copyOf(actorUuids); + actorKeys = actorKeys == null ? List.of() : List.copyOf(actorKeys); + stages = stages == null ? List.of() : List.copyOf(stages); + startContext = startContext == null ? AnimationStartContext.empty() : startContext; + } + } + + @FunctionalInterface + public interface StageAdvance { + void onStageAdvance(AnimationLifecycleEvents.StageChangedContext var1); + } + + public record StageChangedContext( + ServerWorld world, ServerAnimationController.ActiveInstanceSnapshot snapshot, AnimationStageInfo currentStage, long advanceTick + ) { + } + + @FunctionalInterface + public interface Start { + void onStart(AnimationLifecycleEvents.StartedContext var1); + } + + public record StartedContext(ServerWorld world, ServerAnimationController.ActiveInstanceSnapshot snapshot, @Nullable ServerPlayerEntity requester) { + } + + @FunctionalInterface + public interface Stop { + void onStop(AnimationLifecycleEvents.StoppedContext var1); + } + + public record StoppedContext(ServerWorld world, ServerAnimationController.ActiveInstanceSnapshot snapshot) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/api/AnimationService.java b/src/main/java/com/nonid/internal/animation/api/AnimationService.java new file mode 100644 index 0000000..288c802 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/api/AnimationService.java @@ -0,0 +1,508 @@ +package com.nonid.internal.animation.api; + +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.network.StopAllAnimationsS2CPayload; +import com.nonid.internal.animation.server.AnimationStartContext; +import com.nonid.internal.animation.server.ServerAnimationController; +import com.nonid.internal.animation.util.WeightedPicker; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationService { + private AnimationService() { + } + + public static boolean hasEnabledGenericAnimationForEntityType(@Nullable EntityType type, boolean baby) { + return type == null ? false : hasEnabledGenericAnimationForEntityType(Registries.ENTITY_TYPE.getId(type), baby); + } + + public static boolean hasEnabledGenericAnimationForEntityType(@Nullable Identifier entityTypeId, boolean baby) { + return AnimationDefinitions.hasEnabledGenericAnimationForEntityType(entityTypeId, baby); + } + + @Nullable + public static UUID startAnimationNow( + ServerWorld world, Identifier animationId, List actors, @Nullable List actorKeys, @Nullable List stages + ) { + return startAnimationNow(world, animationId, actors, actorKeys, stages, AnimationService.StartOptions.defaults()); + } + + @Nullable + public static UUID startAnimationNow( + ServerWorld world, + Identifier animationId, + List actors, + @Nullable List actorKeys, + @Nullable List stages, + @Nullable AnimationService.StartOptions options + ) { + AnimationService.StartOptions safeOptions = options == null ? AnimationService.StartOptions.defaults() : options; + return startAnimationNowWithContext( + world, animationId, actors, actorKeys, stages, safeOptions, AnimationStartContext.addonMetadata(sanitizeMetadata(safeOptions.metadata())) + ); + } + + @Nullable + public static UUID startAnimationNowWithContext( + ServerWorld world, + Identifier animationId, + List actors, + @Nullable List actorKeys, + @Nullable List stages, + @Nullable AnimationService.StartOptions options, + @Nullable AnimationStartContext startContext + ) { + if (world == null || animationId == null || actors == null || actors.isEmpty()) { + return null; + } + + if (!AnimationDefinitions.isAnimationEnabled(animationId)) { + return null; + } + + AnimationService.StartOptions safeOptions = options == null ? AnimationService.StartOptions.defaults() : options; + List actorList = new ArrayList<>(actors.size()); + actorList.addAll(actors); + actorList.sort(Comparator.comparingInt(Entity::getId)); + UUID positionAnchorUuid = null; + Entity positionAnchor = safeOptions.positionAnchor(); + if (positionAnchor != null && positionAnchor.getEntityWorld() == world) { + UUID candidate = positionAnchor.getUuid(); + boolean anchorIsActor = actorList.stream().anyMatch(e -> e.getUuid().equals(candidate)); + if (anchorIsActor) { + positionAnchorUuid = candidate; + } + } + + return ServerAnimationController.startNowWithContext( + world, + safeOptions.requester(), + animationId, + actorList, + actorKeys, + stages, + safeOptions.damageBehavior() == null ? NonAnimationDamageBehavior.IGNORE_DAMAGE : safeOptions.damageBehavior(), + safeOptions.ignoreAttackers(), + positionAnchorUuid, + startContext == null ? AnimationStartContext.empty() : startContext, + safeOptions.forceChat(), + safeOptions.allowMultiplePlayers() + ); + } + + @Nullable + public static Identifier findMatchedAnimationId(List actors, @Nullable Set requiredAnimationTags) { + if (actors != null && !actors.isEmpty()) { + List actorList = new ArrayList<>(actors.size()); + actorList.addAll(actors); + actorList.sort(Comparator.comparingInt(Entity::getId)); + AnimationDefinitions.MatchResult match = AnimationDefinitions.match(actorList, requiredAnimationTags == null ? Set.of() : requiredAnimationTags); + AnimationDefinitions.Definition chosen = match.chosen(); + return chosen == null ? null : chosen.id(); + } else { + return null; + } + } + + @Nullable + public static AnimationService.MatchedAnimation findAnimationForActors( + ServerWorld world, List actors, @Nullable AnimationService.MatchOptions options + ) { + if (world != null && actors != null && !actors.isEmpty()) { + AnimationService.MatchOptions safeOptions = options == null ? AnimationService.MatchOptions.defaults() : options; + List actorList = new ArrayList<>(actors.size()); + actorList.addAll(actors); + actorList.sort(Comparator.comparingInt(Entity::getId)); + Set safeTags = safeOptions.requiredAnimationTags() == null ? Set.of() : safeOptions.requiredAnimationTags(); + Set excluded = safeOptions.excludedAnimationIds() == null ? Set.of() : safeOptions.excludedAnimationIds(); + UUID positionAnchorUuid = null; + Entity positionAnchor = safeOptions.positionAnchor(); + if (positionAnchor != null && positionAnchor.getEntityWorld() == world) { + UUID candidate = positionAnchor.getUuid(); + boolean anchorIsActor = actors.stream().anyMatch(e -> e != null && e.getUuid().equals(candidate)); + if (anchorIsActor) { + positionAnchorUuid = candidate; + } + } + + List candidates = AnimationDefinitions.findMatchingCandidates(actorList, safeTags); + if (candidates != null && !candidates.isEmpty()) { + List eligible = new ArrayList<>(); + + for (AnimationDefinitions.Definition candidate : candidates) { + if (candidate != null + && !excluded.contains(candidate.id()) + && AnimationDefinitions.isJoinCandidateAllowed(safeOptions.joinPredecessorAnimationId(), candidate)) { + Entity activeActor = safeOptions.requiredActiveActor(); + if (( + activeActor == null + || AnimationDefinitions.canResolveActorAsActivity(candidate, actorList, activeActor, AnimationDefinitions.ActorActivity.ACTIVE) + ) + && ( + !safeOptions.requireStartEligibility() + || ServerAnimationController.isDefinitionStartEligible(world, candidate, actorList, positionAnchorUuid) + )) { + AnimationService.AnimationCandidateFilter candidateFilter = safeOptions.candidateFilter(); + if (candidateFilter == null || candidateFilter.test(world, candidate, List.copyOf(actorList))) { + eligible.add(candidate); + } + } + } + } + + return pickWeightedMatchedAnimation(world, actorList, eligible); + } else { + return null; + } + } else { + return null; + } + } + + public static List findEligibleAnimationsForActors( + ServerWorld world, List actors, @Nullable AnimationService.MatchOptions options + ) { + if (world != null && actors != null && !actors.isEmpty()) { + AnimationService.MatchOptions safeOptions = options == null ? AnimationService.MatchOptions.defaults() : options; + List actorList = new ArrayList<>(actors.size()); + actorList.addAll(actors); + actorList.sort(Comparator.comparingInt(Entity::getId)); + Set safeTags = safeOptions.requiredAnimationTags() == null ? Set.of() : safeOptions.requiredAnimationTags(); + Set excluded = safeOptions.excludedAnimationIds() == null ? Set.of() : safeOptions.excludedAnimationIds(); + Entity positionAnchor = safeOptions.positionAnchor(); + UUID positionAnchorUuid = positionAnchor != null + && positionAnchor.getEntityWorld() == world + && actorList.stream().anyMatch(entity -> entity.getUuid().equals(positionAnchor.getUuid())) + ? positionAnchor.getUuid() + : null; + List result = new ArrayList<>(); + + for (AnimationDefinitions.Definition candidate : AnimationDefinitions.findMatchingCandidates(actorList, safeTags)) { + if (candidate != null + && !excluded.contains(candidate.id()) + && AnimationDefinitions.isJoinCandidateAllowed(safeOptions.joinPredecessorAnimationId(), candidate)) { + Entity activeActor = safeOptions.requiredActiveActor(); + if (activeActor == null + || AnimationDefinitions.canResolveActorAsActivity(candidate, actorList, activeActor, AnimationDefinitions.ActorActivity.ACTIVE)) { + AnimationService.AnimationCandidateFilter filter = safeOptions.candidateFilter(); + if (filter == null || filter.test(world, candidate, List.copyOf(actorList))) { + for (List actorKeys : AnimationDefinitions.resolveAllActorKeyAssignments(candidate, actorList)) { + if (!safeOptions.requireStartEligibility() + || ServerAnimationController.isDefinitionStartEligible(world, candidate, actorList, actorKeys, positionAnchorUuid)) { + AnimationDefinitions.AnimationPackInfo pack = candidate.packInfo(); + result.add( + new AnimationService.EligibleAnimation( + candidate.id(), + List.copyOf(actorKeys), + candidate.stages() == null ? List.of() : List.copyOf(candidate.stages()), + candidate.contentTags() == null ? List.of() : List.copyOf(candidate.contentTags()), + pack == null ? null : pack.id(), + pack == null ? null : pack.name(), + pack == null ? null : pack.author(), + candidate.displayName() + ) + ); + } + } + } + } + } + } + + result.sort( + Comparator.comparing(candidatex -> candidatex.animationId().toString()) + .thenComparing(candidatex -> String.join("\u0000", candidatex.actorKeys())) + ); + return List.copyOf(result); + } else { + return List.of(); + } + } + + @Nullable + private static AnimationService.MatchedAnimation pickWeightedMatchedAnimation( + ServerWorld world, List actors, List definitions + ) { + if (world != null && actors != null && definitions != null && !definitions.isEmpty()) { + AnimationDefinitions.Definition picked = WeightedPicker.pick(definitions, AnimationDefinitions.Definition::weight, world.getRandom().nextDouble()); + if (picked == null) { + return null; + } + + List actorKeys = AnimationDefinitions.resolveActorKeys(picked, actors, world.getRandom()); + return actorKeys != null && actorKeys.size() == actors.size() + ? new AnimationService.MatchedAnimation(picked.id(), List.copyOf(actorKeys), picked.stages() == null ? List.of() : List.copyOf(picked.stages())) + : null; + } else { + return null; + } + } + + public static boolean isAnimationStartEligible( + ServerWorld world, @Nullable AnimationDefinitions.Definition definition, List actors, @Nullable Entity positionAnchor + ) { + if (world != null && actors != null && !actors.isEmpty()) { + UUID positionAnchorUuid = null; + if (positionAnchor != null && positionAnchor.getEntityWorld() == world) { + UUID candidate = positionAnchor.getUuid(); + boolean anchorIsActor = actors.stream().anyMatch(e -> e != null && e.getUuid().equals(candidate)); + if (anchorIsActor) { + positionAnchorUuid = candidate; + } + } + + return ServerAnimationController.isDefinitionStartEligible(world, definition, actors, positionAnchorUuid); + } else { + return false; + } + } + + public static boolean isAnimationStartEligible( + ServerWorld world, + @Nullable AnimationDefinitions.Definition definition, + List actors, + List actorKeys, + @Nullable Entity positionAnchor + ) { + if (world != null && actors != null && !actors.isEmpty()) { + UUID positionAnchorUuid = null; + if (positionAnchor != null && positionAnchor.getEntityWorld() == world) { + UUID candidate = positionAnchor.getUuid(); + boolean anchorIsActor = actors.stream().anyMatch(e -> e != null && e.getUuid().equals(candidate)); + if (anchorIsActor) { + positionAnchorUuid = candidate; + } + } + + return ServerAnimationController.isDefinitionStartEligible(world, definition, actors, actorKeys, positionAnchorUuid); + } else { + return false; + } + } + + public static boolean advanceStage(ServerWorld world, UUID instanceId) { + return ServerAnimationController.advanceStage(world, instanceId); + } + + public static boolean advanceToStage(ServerWorld world, UUID instanceId, int stageNumber) { + return ServerAnimationController.advanceToStage(world, instanceId, stageNumber); + } + + public static void adjustSpeedSilently(ServerWorld world, UUID instanceId, double multiplier) { + ServerAnimationController.adjustSpeedSilently(world, instanceId, multiplier); + } + + @Nullable + public static AnimationStageInfo getCurrentStage(ServerWorld world, UUID instanceId) { + return ServerAnimationController.getCurrentStage(world, instanceId); + } + + @Nullable + public static Identifier getActiveAnimationId(ServerWorld world, UUID instanceId) { + ServerAnimationController.ActiveInstanceSnapshot snapshot = ServerAnimationController.getActiveInstanceSnapshot(world, instanceId); + return snapshot == null ? null : snapshot.animationId(); + } + + public static boolean isJoinTransitionAllowed(@Nullable Identifier predecessorAnimationId, @Nullable Identifier successorAnimationId) { + return predecessorAnimationId != null && successorAnimationId != null + ? AnimationDefinitions.isJoinCandidateAllowed(predecessorAnimationId, AnimationDefinitions.getDefinition(successorAnimationId)) + : false; + } + + public static boolean stopAnimation(ServerWorld world, UUID instanceId) { + return ServerAnimationController.stopInstance(world, instanceId); + } + + public static boolean enqueueAnimation( + ServerWorld world, + Identifier animationId, + List actors, + int insertIndex, + @Nullable NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Map metadata, + @Nullable ServerPlayerEntity requester + ) { + return ServerAnimationController.enqueueForPlayerWithContext( + world, + requester, + animationId, + actors, + insertIndex, + damageBehavior == null ? NonAnimationDamageBehavior.IGNORE_DAMAGE : damageBehavior, + ignoreAttackers, + AnimationStartContext.addonMetadata(sanitizeMetadata(metadata)), + false + ); + } + + public static void clearQueuedAnimations(ServerWorld world, ServerPlayerEntity player) { + ServerAnimationController.clearPlayerQueue(world, player); + } + + public static boolean isActorPendingOrActive(ServerWorld world, UUID actorUuid) { + return ServerAnimationController.isActorPendingOrActive(world, actorUuid); + } + + public static boolean shouldIgnoreAttackers(ServerWorld world, LivingEntity actor) { + return world != null && actor != null ? ServerAnimationController.shouldIgnoreAttackers(world, actor) : false; + } + + public static void setAnimationStartProtection(ServerPlayerEntity player, int ticks) { + ServerAnimationController.setAnimationStartProtection(player, ticks); + } + + public static void clearAnimationStartProtection(ServerPlayerEntity player) { + ServerAnimationController.clearAnimationStartProtection(player); + } + + public static boolean hasAnimationStartProtection(ServerWorld world, ServerPlayerEntity player) { + return ServerAnimationController.hasAnimationStartProtection(world, player); + } + + public static void stopAll(ServerWorld world) { + long stopTick = world.getTime() + 1L; + StopAllAnimationsS2CPayload payload = new StopAllAnimationsS2CPayload(stopTick); + + for (ServerPlayerEntity player : Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayerList()) { + if (player.getEntityWorld() == world) { + ServerPlayNetworking.send(player, payload); + } + } + + ServerAnimationController.clearAllInstancesInWorld(world); + } + + private static Map sanitizeMetadata(@Nullable Map metadata) { + if (metadata != null && !metadata.isEmpty()) { + LinkedHashMap out = new LinkedHashMap<>(); + + for (Entry entry : metadata.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key != null && !key.isBlank() && value != null) { + out.put(key, value); + } + } + } + + return out.isEmpty() ? Map.of() : Map.copyOf(out); + } else { + return Map.of(); + } + } + + @FunctionalInterface + public interface AnimationCandidateFilter { + boolean test(ServerWorld var1, AnimationDefinitions.Definition var2, List var3); + } + + public record EligibleAnimation( + Identifier animationId, + List actorKeys, + List stages, + List contentTags, + @Nullable String packId, + @Nullable String packName, + @Nullable String packAuthor, + @Nullable String displayName + ) { + public EligibleAnimation( + Identifier animationId, + List actorKeys, + List stages, + List contentTags, + @Nullable String packId, + @Nullable String packName, + @Nullable String packAuthor + ) { + this(animationId, actorKeys, stages, contentTags, packId, packName, packAuthor, null); + } + } + + public record MatchOptions( + @Nullable Set requiredAnimationTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable AnimationService.AnimationCandidateFilter candidateFilter, + @Nullable Identifier joinPredecessorAnimationId + ) { + public MatchOptions( + @Nullable Set requiredAnimationTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds, + @Nullable AnimationService.AnimationCandidateFilter candidateFilter + ) { + this(requiredAnimationTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, candidateFilter, null); + } + + public MatchOptions( + @Nullable Set requiredAnimationTags, + @Nullable Entity positionAnchor, + boolean requireStartEligibility, + @Nullable Entity requiredActiveActor, + @Nullable Set excludedAnimationIds + ) { + this(requiredAnimationTags, positionAnchor, requireStartEligibility, requiredActiveActor, excludedAnimationIds, null); + } + + public static AnimationService.MatchOptions defaults() { + return new AnimationService.MatchOptions(Set.of(), null, false, null, Set.of(), null); + } + + public static AnimationService.MatchOptions startEligible(@Nullable Entity positionAnchor) { + return new AnimationService.MatchOptions(Set.of(), positionAnchor, true, null, Set.of(), null); + } + } + + public record MatchedAnimation(Identifier animationId, List actorKeys, List stages) { + } + + public record StartOptions( + @Nullable NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + @Nullable ServerPlayerEntity requester, + @Nullable Map metadata, + boolean forceChat, + boolean allowMultiplePlayers + ) { + public StartOptions( + @Nullable NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable Entity positionAnchor, + @Nullable ServerPlayerEntity requester, + @Nullable Map metadata, + boolean forceChat + ) { + this(damageBehavior, ignoreAttackers, positionAnchor, requester, metadata, forceChat, false); + } + + public static AnimationService.StartOptions defaults() { + return new AnimationService.StartOptions(NonAnimationDamageBehavior.IGNORE_DAMAGE, false, null, null, Map.of(), false, false); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/api/NonAnimationApiBridge.java b/src/main/java/com/nonid/internal/animation/api/NonAnimationApiBridge.java new file mode 100644 index 0000000..12c9c0e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/api/NonAnimationApiBridge.java @@ -0,0 +1,74 @@ +package com.nonid.internal.animation.api; + +import com.nonid.api.animation.NonAnimationEvents; +import com.nonid.internal.animation.server.ServerAnimationController; + +public final class NonAnimationApiBridge { + private static boolean initialized; + + private NonAnimationApiBridge() { + } + + public static synchronized void initialize() { + if (!initialized) { + initialized = true; + AnimationLifecycleEvents.ALLOW_START + .register( + (AnimationLifecycleEvents.AllowStart)context -> ((NonAnimationEvents.BeforeStart)NonAnimationEvents.BEFORE_START.invoker()) + .allow( + new NonAnimationEvents.BeforeStartContext( + context.world(), + context.animationId(), + context.actorUuids(), + context.actorKeys(), + NonAnimationViews.stages(context.stages()), + context.requester(), + context.damageBehavior(), + context.ignoreAttackers(), + context.startContext().addonMetadata() + ) + ) + ); + AnimationLifecycleEvents.START + .register( + (AnimationLifecycleEvents.Start)context -> ((NonAnimationEvents.Started)NonAnimationEvents.STARTED.invoker()) + .onStarted(new NonAnimationEvents.StartedContext(context.world(), NonAnimationViews.session(context.snapshot()), context.requester())) + ); + AnimationLifecycleEvents.STOP + .register( + (AnimationLifecycleEvents.Stop)context -> { + ServerAnimationController.ActiveInstanceSnapshot snapshot = context.snapshot(); + ((NonAnimationEvents.Stopped)NonAnimationEvents.STOPPED.invoker()) + .onStopped( + new NonAnimationEvents.StoppedContext( + context.world(), + snapshot.instanceId(), + snapshot.animationId(), + snapshot.actorUuids(), + snapshot.actorKeys(), + NonAnimationViews.stages(snapshot.stages()) + ) + ); + } + ); + AnimationLifecycleEvents.STAGE_ADVANCE + .register( + (AnimationLifecycleEvents.StageAdvance)context -> { + ServerAnimationController.ActiveInstanceSnapshot snapshot = context.snapshot(); + ((NonAnimationEvents.StageChanged)NonAnimationEvents.STAGE_CHANGED.invoker()) + .onStageChanged( + new NonAnimationEvents.StageChangedContext( + context.world(), + snapshot.instanceId(), + snapshot.animationId(), + snapshot.actorUuids(), + snapshot.actorKeys(), + context.advanceTick(), + NonAnimationViews.stage(context.currentStage()) + ) + ); + } + ); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/api/NonAnimationViews.java b/src/main/java/com/nonid/internal/animation/api/NonAnimationViews.java new file mode 100644 index 0000000..1473ed9 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/api/NonAnimationViews.java @@ -0,0 +1,56 @@ +package com.nonid.internal.animation.api; + +import com.nonid.api.animation.NonAnimationSession; +import com.nonid.api.animation.NonAnimationStage; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.server.ServerAnimationController; +import java.util.List; +import org.jetbrains.annotations.Nullable; + +public final class NonAnimationViews { + private NonAnimationViews() { + } + + @Nullable + public static NonAnimationStage stage(@Nullable AnimationStageInfo stage) { + return stage == null + ? null + : new NonAnimationStage( + stage.animationId(), + stage.loop(), + stage.lengthTicks(), + stage.allowJoin(), + stage.speed(), + stage.playbackAnimationId(), + stage.actorProps() + .stream() + .map(prop -> new NonAnimationStage.ActorProp(prop.actorKey(), prop.propLeftItemId(), prop.propRightItemId(), prop.propFloorItemId())) + .toList(), + stage.cycleMidpointOffsetSeconds(), + stage.cycleTicks() + ); + } + + public static List stages(@Nullable List stages) { + return stages != null && !stages.isEmpty() ? stages.stream().map(NonAnimationViews::stage).toList() : List.of(); + } + + public static NonAnimationSession session(ServerAnimationController.ActiveInstanceSnapshot snapshot) { + List stages = snapshot.stages(); + NonAnimationStage currentStage = null; + if (stages != null && !stages.isEmpty()) { + int index = Math.max(0, Math.min(snapshot.stageIndex(), stages.size() - 1)); + currentStage = stage(stages.get(index)); + } + + return new NonAnimationSession( + snapshot.instanceId(), + snapshot.animationId(), + snapshot.actorUuids(), + currentStage, + snapshot.damageBehavior(), + snapshot.ignoreAttackers(), + snapshot.metadata() + ); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/camera/AnimatedCameraPoseTracker.java b/src/main/java/com/nonid/internal/animation/client/camera/AnimatedCameraPoseTracker.java new file mode 100644 index 0000000..7e138cb --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/camera/AnimatedCameraPoseTracker.java @@ -0,0 +1,76 @@ +package com.nonid.internal.animation.client.camera; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; + +public final class AnimatedCameraPoseTracker { + private static final long HOLD_TICKS = 20L; + private static final Map SAMPLES = new ConcurrentHashMap<>(); + + private AnimatedCameraPoseTracker() { + } + + public static void capture( + UUID actorUuid, + UUID instanceId, + Identifier animationId, + Vec3d worldPos, + Vec3d bodyWorldPos, + Vec3d headForward, + Vec3d headUp, + Vec3d headRight, + Vec3d bodyForward, + Vec3d bodyUp, + Vec3d bodyRight, + long tick + ) { + if (actorUuid != null && worldPos != null) { + SAMPLES.put( + actorUuid, + new AnimatedCameraPoseTracker.PoseSample( + instanceId, animationId, worldPos, bodyWorldPos, headForward, headUp, headRight, bodyForward, bodyUp, bodyRight, tick + ) + ); + } + } + + @Nullable + public static AnimatedCameraPoseTracker.PoseSample resolve(UUID actorUuid, long nowTick) { + AnimatedCameraPoseTracker.PoseSample sample = SAMPLES.get(actorUuid); + if (sample == null) { + return null; + } else if (nowTick - sample.tick() > 20L) { + SAMPLES.remove(actorUuid, sample); + return null; + } else { + return sample; + } + } + + public static void prune(long nowTick) { + SAMPLES.entrySet().removeIf(entry -> nowTick - entry.getValue().tick() > 20L); + } + + public static void clear() { + SAMPLES.clear(); + } + + public record PoseSample( + UUID instanceId, + Identifier animationId, + Vec3d worldPos, + Vec3d bodyWorldPos, + Vec3d headForward, + Vec3d headUp, + Vec3d headRight, + Vec3d bodyForward, + Vec3d bodyUp, + Vec3d bodyRight, + long tick + ) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/camera/AnimationCameraZoom.java b/src/main/java/com/nonid/internal/animation/client/camera/AnimationCameraZoom.java new file mode 100644 index 0000000..0138058 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/camera/AnimationCameraZoom.java @@ -0,0 +1,64 @@ +package com.nonid.internal.animation.client.camera; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.option.Perspective; +import net.minecraft.util.math.MathHelper; + +public final class AnimationCameraZoom { + private static final float DEFAULT_DISTANCE = 4.0F; + private static final float MIN_DISTANCE = 0.5F; + private static final float MAX_DISTANCE = 8.0F; + private static final float SCROLL_STEP = 0.25F; + private static final float SMOOTHING_PER_TICK = 0.35F; + private static final float SNAP_EPSILON = 0.005F; + private static float previousDistance = 4.0F; + private static float currentDistance = 4.0F; + private static float targetDistance = 4.0F; + + private AnimationCameraZoom() { + } + + public static boolean handleScroll(MinecraftClient client, double verticalScroll) { + if (verticalScroll != 0.0 && isThirdPersonZoomAvailable(client)) { + float direction = verticalScroll > 0.0 ? -1.0F : 1.0F; + targetDistance = MathHelper.clamp(targetDistance + direction * 0.25F, 0.5F, 8.0F); + return true; + } else { + return false; + } + } + + public static void clientTick(MinecraftClient client) { + if (!isLocalPlayerAnimating(client)) { + reset(); + } else { + previousDistance = currentDistance; + float delta = targetDistance - currentDistance; + if (Math.abs(delta) <= 0.005F) { + currentDistance = targetDistance; + } else { + currentDistance = MathHelper.lerp(0.35F, currentDistance, targetDistance); + } + } + } + + public static float distance(float tickDelta) { + float progress = MathHelper.clamp(tickDelta, 0.0F, 1.0F); + return MathHelper.lerp(progress, previousDistance, currentDistance); + } + + public static void reset() { + previousDistance = 4.0F; + currentDistance = 4.0F; + targetDistance = 4.0F; + } + + public static boolean isLocalPlayerAnimating(MinecraftClient client) { + return client != null && client.player != null && ClientAnimationRuntime.isActorPendingOrActive(client.player.getUuid()); + } + + private static boolean isThirdPersonZoomAvailable(MinecraftClient client) { + return isLocalPlayerAnimating(client) && client.currentScreen == null && client.options.getPerspective() != Perspective.FIRST_PERSON; + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/camera/CameraPositionAccess.java b/src/main/java/com/nonid/internal/animation/client/camera/CameraPositionAccess.java new file mode 100644 index 0000000..9bf09a0 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/camera/CameraPositionAccess.java @@ -0,0 +1,7 @@ +package com.nonid.internal.animation.client.camera; + +import net.minecraft.util.math.Vec3d; + +public interface CameraPositionAccess { + Vec3d afw$getPos(); +} diff --git a/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonCameraCollision.java b/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonCameraCollision.java new file mode 100644 index 0000000..cb9446e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonCameraCollision.java @@ -0,0 +1,95 @@ +package com.nonid.internal.animation.client.camera; + +import net.minecraft.util.math.Box; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.shape.VoxelShape; +import net.minecraft.world.CollisionView; +import org.jetbrains.annotations.Nullable; + +public final class FirstPersonCameraCollision { + private static final double CAMERA_BOX_SIZE = 0.25; + private static final double PUSH_EPSILON = 0.005; + private static final int MAX_PUSH_ITERATIONS = 8; + + private FirstPersonCameraCollision() { + } + + public static Vec3d resolve(CollisionView world, Vec3d desiredPos) { + if (world != null && desiredPos != null) { + Vec3d resolvedPos = desiredPos; + + for (int i = 0; i < 8; i++) { + Box cameraBox = Box.of(resolvedPos, 0.25, 0.25, 0.25); + if (world.isSpaceEmpty(cameraBox)) { + return resolvedPos; + } + + Vec3d push = computeMinimalPush(world, cameraBox); + if (push == null || push.lengthSquared() < 1.0E-10) { + return resolvedPos; + } + + resolvedPos = resolvedPos.add(push); + } + + return resolvedPos; + } else { + return desiredPos; + } + } + + @Nullable + private static Vec3d computeMinimalPush(CollisionView world, Box cameraBox) { + double bestDistanceSq = Double.POSITIVE_INFINITY; + Vec3d bestPush = null; + + for (VoxelShape shape : world.getBlockCollisions(null, cameraBox.expand(0.005))) { + for (Box blockBox : shape.getBoundingBoxes()) { + if (cameraBox.intersects(blockBox)) { + Vec3d candidate = smallestSeparatingPush(cameraBox, blockBox); + if (candidate != null) { + double distanceSq = candidate.lengthSquared(); + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq; + bestPush = candidate; + } + } + } + } + } + + return bestPush; + } + + @Nullable + private static Vec3d smallestSeparatingPush(Box cameraBox, Box blockBox) { + double pushNegX = blockBox.minX - cameraBox.maxX - 0.005; + double pushPosX = blockBox.maxX - cameraBox.minX + 0.005; + double pushNegY = blockBox.minY - cameraBox.maxY - 0.005; + double pushPosY = blockBox.maxY - cameraBox.minY + 0.005; + double pushNegZ = blockBox.minZ - cameraBox.maxZ - 0.005; + double pushPosZ = blockBox.maxZ - cameraBox.minZ + 0.005; + Vec3d[] candidates = new Vec3d[]{ + new Vec3d(pushNegX, 0.0, 0.0), + new Vec3d(pushPosX, 0.0, 0.0), + new Vec3d(0.0, pushNegY, 0.0), + new Vec3d(0.0, pushPosY, 0.0), + new Vec3d(0.0, 0.0, pushNegZ), + new Vec3d(0.0, 0.0, pushPosZ) + }; + Vec3d best = null; + double bestDistanceSq = Double.POSITIVE_INFINITY; + + for (Vec3d candidate : candidates) { + if (!(candidate.lengthSquared() <= 1.0E-10)) { + double distanceSq = candidate.lengthSquared(); + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq; + best = candidate; + } + } + } + + return best; + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonLookConstraint.java b/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonLookConstraint.java new file mode 100644 index 0000000..3c25203 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/camera/FirstPersonLookConstraint.java @@ -0,0 +1,142 @@ +package com.nonid.internal.animation.client.camera; + +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; + +public final class FirstPersonLookConstraint { + private static final double BACKWARD_HARD_DEGREES = 160.0; + private static final double DOWNWARD_HARD_DEGREES = 60.0; + private static final double BACKWARD_FULL_LIMIT_UPWARD_PITCH_DEGREES = 20.0; + private static final double BACKWARD_DISABLE_UPWARD_PITCH_DEGREES = 40.0; + private static final double MIN_HORIZONTAL_LENGTH_FOR_YAW = 0.001; + + private FirstPersonLookConstraint() { + } + + public static Vec3d constrain(Vec3d desiredLook, @Nullable AnimatedCameraPoseTracker.PoseSample pose, @Nullable Vec3d previousConstrainedLook) { + if (desiredLook == null) { + return Vec3d.ZERO; + } + + Vec3d normalizedLook = desiredLook.normalize(); + if (pose == null) { + return normalizedLook; + } + + Vec3d bodyForward = normalize(pose.bodyForward()); + Vec3d bodyUp = normalize(pose.bodyUp()); + Vec3d bodyRight = normalize(pose.bodyRight()); + if (bodyForward != null && bodyUp != null && bodyRight != null) { + FirstPersonLookConstraint.LocalAngles desiredAngles = toLocalAngles(normalizedLook, bodyForward, bodyUp, bodyRight); + if (desiredAngles == null) { + return normalizedLook; + } + + double localYaw = desiredAngles.yawDegrees(); + double localPitch = desiredAngles.pitchDegrees(); + FirstPersonLookConstraint.LocalAngles previousAngles = toLocalAngles(previousConstrainedLook, bodyForward, bodyUp, bodyRight); + if (previousAngles != null) { + localYaw = unwrapNear(localYaw, previousAngles.yawDegrees()); + } + + double constrainedPitch = constrainDownward(localPitch, 60.0); + double effectiveBackwardLimit = computeEffectiveBackwardLimit(localPitch); + double constrainedYaw = constrainSymmetric(localYaw, effectiveBackwardLimit); + if (Math.abs(constrainedYaw - localYaw) < 1.0E-4 && Math.abs(constrainedPitch - localPitch) < 1.0E-4) { + return normalizedLook; + } + + double yawRad = Math.toRadians(constrainedYaw); + double pitchRad = Math.toRadians(constrainedPitch); + double correctedHorizontal = Math.cos(pitchRad); + Vec3d correctedLocal = new Vec3d(Math.sin(yawRad) * correctedHorizontal, Math.sin(pitchRad), Math.cos(yawRad) * correctedHorizontal); + Vec3d correctedWorld = bodyRight.multiply(correctedLocal.x).add(bodyUp.multiply(correctedLocal.y)).add(bodyForward.multiply(correctedLocal.z)); + return correctedWorld.normalize(); + } else { + return normalizedLook; + } + } + + public static float toMinecraftYaw(Vec3d lookVector, float fallbackYaw) { + Vec3d normalized = lookVector == null ? Vec3d.ZERO : lookVector.normalize(); + return normalized.horizontalLengthSquared() <= 0.001 ? fallbackYaw : (float)Math.toDegrees(Math.atan2(-normalized.x, normalized.z)); + } + + public static float toMinecraftPitch(Vec3d lookVector) { + Vec3d normalized = lookVector == null ? Vec3d.ZERO : lookVector.normalize(); + double clampedY = MathHelper.clamp(normalized.y, -1.0, 1.0); + return (float)(-Math.toDegrees(Math.asin(clampedY))); + } + + private static double constrainSymmetric(double angleDegrees, double hardLimit) { + double sign = Math.signum(angleDegrees); + double absolute = Math.abs(angleDegrees); + return absolute <= hardLimit ? angleDegrees : sign * hardLimit; + } + + private static double constrainDownward(double pitchDegrees, double hardLimit) { + double downward = Math.max(0.0, -pitchDegrees); + return downward <= hardLimit ? pitchDegrees : -hardLimit; + } + + private static double computeEffectiveBackwardLimit(double localPitchDegrees) { + if (localPitchDegrees <= 20.0) { + return 160.0; + } + + if (localPitchDegrees >= 40.0) { + return 180.0; + } + + double t = (localPitchDegrees - 20.0) / Math.max(1.0E-6, 20.0); + return MathHelper.lerp(t, 160.0, 180.0); + } + + @Nullable + private static Vec3d normalize(@Nullable Vec3d vector) { + if (vector == null) { + return null; + } + + if (vector.lengthSquared() < 1.0E-8) { + return null; + } + + Vec3d normalized = vector.normalize(); + return !normalized.isFinite() ? null : normalized; + } + + @Nullable + private static FirstPersonLookConstraint.LocalAngles toLocalAngles(@Nullable Vec3d look, Vec3d bodyForward, Vec3d bodyUp, Vec3d bodyRight) { + Vec3d normalizedLook = normalize(look); + if (normalizedLook == null) { + return null; + } + + double localX = normalizedLook.dotProduct(bodyRight); + double localY = normalizedLook.dotProduct(bodyUp); + double localZ = normalizedLook.dotProduct(bodyForward); + double horizontalLength = Math.sqrt(localX * localX + localZ * localZ); + double localYaw = Math.toDegrees(Math.atan2(localX, localZ)); + double localPitch = Math.toDegrees(Math.atan2(localY, Math.max(1.0E-6, horizontalLength))); + return new FirstPersonLookConstraint.LocalAngles(localYaw, localPitch); + } + + private static double unwrapNear(double angleDegrees, double referenceDegrees) { + double unwrapped = angleDegrees; + + while (unwrapped - referenceDegrees > 180.0) { + unwrapped -= 360.0; + } + + while (unwrapped - referenceDegrees < -180.0) { + unwrapped += 360.0; + } + + return unwrapped; + } + + private record LocalAngles(double yawDegrees, double pitchDegrees) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderCompat.java b/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderCompat.java new file mode 100644 index 0000000..bb83831 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderCompat.java @@ -0,0 +1,955 @@ +package com.nonid.internal.animation.client.compat.wildfire; + +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.mixin.client.EntityRenderManagerAccessor; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.state.BipedEntityRenderState; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.EntityPose; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import org.joml.Vector3f; + +public final class WildfireGenderCompat { + private static final String MOD_ID = "wildfire_gender"; + private static final WildfireGenderState.UvRect DEFAULT_LEFT_BASE_UV = WildfireGenderState.UvRect.pixels(20, 20, 24, 26); + private static final WildfireGenderState.UvRect DEFAULT_RIGHT_BASE_UV = WildfireGenderState.UvRect.pixels(24, 20, 28, 26); + private static final WildfireGenderState.UvRect DEFAULT_LEFT_OVERLAY_UV = WildfireGenderState.UvRect.pixels(20, 36, 24, 42); + private static final WildfireGenderState.UvRect DEFAULT_RIGHT_OVERLAY_UV = WildfireGenderState.UvRect.pixels(24, 36, 28, 42); + private static final float POSITION_X_MIN = -1.25F; + private static final float POSITION_X_MAX = 1.25F; + private static final float POSITION_Y_MIN = -1.25F; + private static final float POSITION_Y_MAX = 1.75F; + private static final float POSITION_REFERENCE_DELTA = 0.08F; + private static final float POSITION_RESPONSE_EXPONENT = 0.55F; + private static final float PHYSICS_TARGET_RESPONSE = 0.1F; + private static final float GRAVITY_MOVEMENT_BOOST = 0.8F; + private static final float GRAVITY_SIDE_POSITION_SCALE = 0.35F; + private static final float GRAVITY_FORWARD_POSITION_SCALE = 0.45F; + private static final float LOCAL_FORWARD_VERTICAL_SCALE = 0.4F; + private static final float ROTATION_YAW_DIVISOR = 15.0F; + private static final float ROTATION_VERTICAL_SCALE = 18.0F; + private static final float ROTATION_LATERAL_SCALE = 16.0F; + private static final float ROTATION_FORWARD_SCALE = 20.0F; + private static final float GRAVITY_SIDE_ROTATION_SCALE = 4.0F; + private static final float GRAVITY_FORWARD_ROTATION_SCALE = 6.0F; + private static final float ROTATION_MIN = -25.0F; + private static final float ROTATION_MAX = 25.0F; + private static final float CHEST_SAMPLE_LOCAL_X = 0.0F; + private static final float CHEST_SAMPLE_LOCAL_Y = 0.3F; + private static final float CHEST_SAMPLE_LOCAL_Z = -0.35F; + private static final float BOUNCE_INTENSITY_SCALE = 12.0F; + private static final float BOUNCE_INTENSITY_MIN = 0.15F; + private static final float BOUNCE_INTENSITY_MAX = 3.0F; + private static final float RENDER_GRAVITY_POSITION_X_SCALE = 0.45F; + private static final float RENDER_GRAVITY_POSITION_Y_SCALE = 0.85F; + private static final float RENDER_GRAVITY_ROTATION_SIDE_SCALE = 10.0F; + private static final float RENDER_GRAVITY_ROTATION_FORWARD_SCALE = 2.0F; + private static final float RENDER_GRAVITY_POSITION_X_LIMIT = 0.8F; + private static final float RENDER_GRAVITY_POSITION_Y_LIMIT = 1.2F; + private static final float RENDER_GRAVITY_ROTATION_LIMIT = 10.0F; + private static boolean initialized; + private static boolean available; + private static boolean loggedFailure; + private static Method getRenderState; + private static Field genderField; + private static Field bustSizeField; + private static Field armorPhysicsOverrideField; + private static Field showBreastsInArmorField; + private static Field hasJacketLayerField; + private static Field breastsField; + private static Field leftPhysicsField; + private static Field rightPhysicsField; + private static Field leftUvField; + private static Field rightUvField; + private static Field leftOverlayUvField; + private static Field rightOverlayUvField; + private static Method genderCanHaveBreasts; + private static Field breastXOffsetField; + private static Field breastYOffsetField; + private static Field breastZOffsetField; + private static Field breastCleavageField; + private static Field breastUniboobField; + private static Method physicsPositionXMethod; + private static Method physicsPositionYMethod; + private static Method physicsBreastSizeMethod; + private static Method physicsBounceRotationMethod; + private static Field physicsPrePositionXField; + private static Field physicsPositionXField; + private static Field physicsPrePositionYField; + private static Field physicsPositionYField; + private static Field physicsPreBounceRotationField; + private static Field physicsBounceRotationField; + private static Method uvGetAllSidesMethod; + private static Method uvQuadX1Method; + private static Method uvQuadY1Method; + private static Method uvQuadX2Method; + private static Method uvQuadY2Method; + private static Class genderLayerClass; + private static Method genderLayerSubmitMethod; + private static Method contextModelMethod; + private static Field modelBodyField; + private static Field bodyOriginXField; + private static Field bodyOriginYField; + private static Field bodyOriginZField; + private static Field bodyPitchField; + private static Field bodyYawField; + private static Field bodyRollField; + private static Method entityConfigGetEntityMethod; + private static Constructor breastPhysicsConstructor; + private static Method breastPhysicsFinishTickMethod; + private static Field breastPhysicsTargetBounceXField; + private static Field breastPhysicsTargetBounceYField; + private static Field breastPhysicsTargetRotVelField; + private static Field breastPhysicsPrePositionXField; + private static Field breastPhysicsPositionXField; + private static Field breastPhysicsPrePositionYField; + private static Field breastPhysicsPositionYField; + private static Field breastPhysicsPreBounceRotationField; + private static Field breastPhysicsBounceRotationField; + private static final Map GENDER_LAYERS = new IdentityHashMap<>(); + private static final Map ANIMATION_PHYSICS = new HashMap<>(); + + private WildfireGenderCompat() { + } + + public static WildfireGenderState resolve(LivingEntity entity, LivingEntityRenderState renderState) { + if (entity == null || renderState == null) { + return null; + } + + if (!isAvailable()) { + return null; + } + + try { + Object wildfireState = getRenderState.invoke(null, renderState); + if (wildfireState == null) { + return null; + } + + Object gender = genderField.get(wildfireState); + if (!canHaveBreasts(gender)) { + return null; + } + + Object breasts = breastsField.get(wildfireState); + if (breasts == null) { + return null; + } + + cacheAnimationPhysics(entity); + float bustSize = getFloat(bustSizeField, wildfireState, 0.0F); + if (bustSize <= 0.02F) { + return null; + } + + boolean hasJacketLayer = getBoolean(hasJacketLayerField, wildfireState, false); + float xOffset = getFloat(breastXOffsetField, breasts, 0.0F); + float yOffset = getFloat(breastYOffsetField, breasts, 0.0F); + float zOffset = getFloat(breastZOffsetField, breasts, 0.0F); + float cleavage = getFloat(breastCleavageField, breasts, 0.05F); + boolean uniboob = getBoolean(breastUniboobField, breasts, false); + return new WildfireGenderState( + bustSize, + xOffset, + yOffset, + zOffset, + cleavage, + uniboob, + hasJacketLayer, + readSide( + leftPhysicsField.get(wildfireState), + leftUvField.get(wildfireState), + leftOverlayUvField.get(wildfireState), + DEFAULT_LEFT_BASE_UV, + DEFAULT_LEFT_OVERLAY_UV + ), + readSide( + rightPhysicsField.get(wildfireState), + rightUvField.get(wildfireState), + rightOverlayUvField.get(wildfireState), + DEFAULT_RIGHT_BASE_UV, + DEFAULT_RIGHT_OVERLAY_UV + ) + ); + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure("Failed to read Female Gender render state.", e); + return null; + } + } + + public static boolean submitOriginalLayer(LivingEntityRenderState renderState, MatrixStack matrices, OrderedRenderCommandQueue queue, int packedLight) { + if (renderState == null || matrices == null || queue == null) { + return false; + } + + if (!isAvailable()) { + return false; + } + + try { + Object layer = getOrCreateOriginalLayer(renderState); + if (layer != null && genderLayerSubmitMethod != null) { + Object wildfireState = getRenderState.invoke(null, renderState); + Boolean previousShowBreastsInArmor = forceShowBreastsInArmor(wildfireState); + Boolean previousArmorPhysicsOverride = forceArmorPhysicsOverride(wildfireState); + WildfireGenderCompat.RenderPoseSnapshot renderPose = neutralizeRenderPose(renderState); + WildfireGenderCompat.BodyTransformSnapshot bodyTransform = neutralizeContextBodyTransform(layer); + + try { + genderLayerSubmitMethod.invoke(layer, matrices, queue, packedLight, renderState, 0.0F, 0.0F); + return true; + } finally { + restoreContextBodyTransform(bodyTransform); + restoreRenderPose(renderState, renderPose); + restoreShowBreastsInArmor(wildfireState, previousShowBreastsInArmor); + restoreArmorPhysicsOverride(wildfireState, previousArmorPhysicsOverride); + } + } else { + return false; + } + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure("Failed to submit Female Gender original render layer.", e); + return false; + } + } + + @Nullable + private static Boolean forceShowBreastsInArmor(@Nullable Object wildfireState) throws IllegalAccessException { + if (wildfireState != null && showBreastsInArmorField != null) { + boolean previous = showBreastsInArmorField.getBoolean(wildfireState); + showBreastsInArmorField.setBoolean(wildfireState, true); + return previous; + } else { + return null; + } + } + + private static void restoreShowBreastsInArmor(@Nullable Object wildfireState, @Nullable Boolean previous) throws IllegalAccessException { + if (wildfireState != null && previous != null && showBreastsInArmorField != null) { + showBreastsInArmorField.setBoolean(wildfireState, previous); + } + } + + @Nullable + private static Boolean forceArmorPhysicsOverride(@Nullable Object wildfireState) throws IllegalAccessException { + if (wildfireState != null && armorPhysicsOverrideField != null) { + boolean previous = armorPhysicsOverrideField.getBoolean(wildfireState); + armorPhysicsOverrideField.setBoolean(wildfireState, true); + return previous; + } else { + return null; + } + } + + private static void restoreArmorPhysicsOverride(@Nullable Object wildfireState, @Nullable Boolean previous) throws IllegalAccessException { + if (wildfireState != null && previous != null && armorPhysicsOverrideField != null) { + armorPhysicsOverrideField.setBoolean(wildfireState, previous); + } + } + + public static void overrideRenderPhysics(UUID playerUuid, LivingEntityRenderState renderState, MatrixStack rootPose, MatrixStack bonePose) { + if (playerUuid != null && renderState != null && rootPose != null && bonePose != null) { + if (isAvailable()) { + try { + Object wildfireState = getRenderState.invoke(null, renderState); + if (wildfireState == null) { + return; + } + + WildfireGenderCompat.AnimationPhysicsPair physicsPair = ANIMATION_PHYSICS.get(playerUuid); + if (physicsPair == null) { + return; + } + + WildfireGenderCompat.BonePhysicsSample sample = WildfireGenderCompat.BonePhysicsSample.from(rootPose, bonePose); + if (sample == null) { + return; + } + + long worldTick = currentClientWorldTick(); + if (physicsPair.lastSample == null) { + physicsPair.lastSample = sample; + physicsPair.lastTick = worldTick; + } else if (physicsPair.lastTick != worldTick) { + stepAnimationPhysics(wildfireState, physicsPair, physicsPair.lastSample, sample); + physicsPair.lastSample = sample; + physicsPair.lastTick = worldTick; + } + + Object leftPhysics = leftPhysicsField.get(wildfireState); + Object rightPhysics = rightPhysicsField.get(wildfireState); + copyRealPhysicsToRenderState(leftPhysics, physicsPair.left, physicsPair.previousLeftBias, physicsPair.leftBias); + copyRealPhysicsToRenderState(rightPhysics, physicsPair.right, physicsPair.previousRightBias, physicsPair.rightBias); + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure("Failed to override Female Gender render physics from animated bone motion.", e); + } + } + } + } + + private static void stepAnimationPhysics( + Object wildfireState, + WildfireGenderCompat.AnimationPhysicsPair physicsPair, + WildfireGenderCompat.BonePhysicsSample previous, + WildfireGenderCompat.BonePhysicsSample current + ) throws ReflectiveOperationException { + float dx = current.chestX() - previous.chestX(); + float dy = current.chestY() - previous.chestY(); + float dz = current.chestZ() - previous.chestZ(); + Vec3d delta = new Vec3d(dx, dy, dz); + float lateral = (float)delta.dotProduct(previous.right()); + float localUp = (float)delta.dotProduct(previous.up()); + float localForward = (float)delta.dotProduct(previous.forward()); + float yawDelta = signedYawDelta(previous.forward(), current.forward()); + WildfireGenderCompat.GravityBias gravity = WildfireGenderCompat.GravityBias.from(current); + float movementBoost = 1.0F + gravity.tiltInfluence() * 0.8F; + float verticalMovement = localUp + localForward * gravity.forwardDown() * 0.4F; + float bustSize = getFloat(bustSizeField, wildfireState, 0.0F); + float bounceIntensity = clamp(bustSize * 12.0F, 0.15F, 3.0F); + float targetX = shapedImpulse(lateral * movementBoost, 0.08F, -1.25F, 1.25F, 0.55F); + float targetY = shapedImpulse(-verticalMovement * movementBoost, 0.08F, -1.25F, 1.75F, 0.55F); + targetX = clamp(targetX + gravity.sidePull() * 0.35F, -1.25F, 1.25F); + targetY = clamp(targetY + gravity.forwardDown() * 0.45F, -1.25F, 1.75F); + float targetRotVel = clamp( + -(yawDelta / 15.0F) * bounceIntensity + + -localUp * 18.0F * bounceIntensity + + lateral * 16.0F * bounceIntensity + + localForward * gravity.forwardDown() * 20.0F * bounceIntensity + + gravity.sidePull() * 4.0F * bounceIntensity + + gravity.forwardDown() * 6.0F * bounceIntensity, + -25.0F, + 25.0F + ); + boolean uniboob = isUniboob(wildfireState); + physicsPair.stepRenderBias(gravity, uniboob); + WildfireGenderCompat.TargetImpulse smoothedTarget = physicsPair.smoothTarget(targetX, targetY, targetRotVel); + if (uniboob) { + driveRealPhysics(physicsPair.left, smoothedTarget.x(), smoothedTarget.y(), 0.0F); + driveRealPhysics(physicsPair.right, smoothedTarget.x(), smoothedTarget.y(), 0.0F); + } else { + driveRealPhysics(physicsPair.left, smoothedTarget.x(), smoothedTarget.y(), smoothedTarget.rotation()); + driveRealPhysics(physicsPair.right, -smoothedTarget.x() * 0.85F, smoothedTarget.y() * 0.95F, -smoothedTarget.rotation() * 0.85F); + } + } + + private static void cacheAnimationPhysics(LivingEntity entity) throws ReflectiveOperationException { + if (entity != null && entityConfigGetEntityMethod != null) { + Object config = entityConfigGetEntityMethod.invoke(null, entity); + if (config != null) { + UUID uuid = entity.getUuid(); + WildfireGenderCompat.AnimationPhysicsPair existing = ANIMATION_PHYSICS.get(uuid); + if (existing == null || existing.config != config) { + Object left = breastPhysicsConstructor.newInstance(config); + Object right = breastPhysicsConstructor.newInstance(config); + ANIMATION_PHYSICS.put(uuid, new WildfireGenderCompat.AnimationPhysicsPair(config, left, right)); + } + } + } + } + + private static void driveRealPhysics(Object physics, float targetX, float targetY, float targetRotVel) throws ReflectiveOperationException { + if (physics != null) { + breastPhysicsPrePositionXField.setFloat(physics, breastPhysicsPositionXField.getFloat(physics)); + breastPhysicsPrePositionYField.setFloat(physics, breastPhysicsPositionYField.getFloat(physics)); + breastPhysicsPreBounceRotationField.setFloat(physics, breastPhysicsBounceRotationField.getFloat(physics)); + breastPhysicsTargetBounceXField.setFloat(physics, targetX); + breastPhysicsTargetBounceYField.setFloat(physics, targetY); + breastPhysicsTargetRotVelField.setFloat(physics, targetRotVel); + breastPhysicsFinishTickMethod.invoke(physics); + } + } + + private static void copyRealPhysicsToRenderState( + Object renderPhysics, Object realPhysics, WildfireGenderCompat.SideRenderBias previousBias, WildfireGenderCompat.SideRenderBias bias + ) throws IllegalAccessException { + if (renderPhysics != null && realPhysics != null) { + if (physicsPositionXField != null && physicsPrePositionXField != null) { + physicsPrePositionXField.setFloat(renderPhysics, breastPhysicsPrePositionXField.getFloat(realPhysics) + previousBias.x()); + physicsPositionXField.setFloat(renderPhysics, breastPhysicsPositionXField.getFloat(realPhysics) + bias.x()); + } + + if (physicsPositionYField != null && physicsPrePositionYField != null) { + physicsPrePositionYField.setFloat(renderPhysics, breastPhysicsPrePositionYField.getFloat(realPhysics) + previousBias.y()); + physicsPositionYField.setFloat(renderPhysics, breastPhysicsPositionYField.getFloat(realPhysics) + bias.y()); + } + + if (physicsBounceRotationField != null && physicsPreBounceRotationField != null) { + physicsPreBounceRotationField.setFloat(renderPhysics, breastPhysicsPreBounceRotationField.getFloat(realPhysics) + previousBias.rotation()); + physicsBounceRotationField.setFloat(renderPhysics, breastPhysicsBounceRotationField.getFloat(realPhysics) + bias.rotation()); + } + } + } + + private static long currentClientWorldTick() { + MinecraftClient client = MinecraftClient.getInstance(); + return client != null && client.world != null ? client.world.getTime() : Long.MIN_VALUE; + } + + private static boolean isUniboob(Object wildfireState) throws ReflectiveOperationException { + Object breasts = breastsField.get(wildfireState); + return breasts != null && getBoolean(breastUniboobField, breasts, false); + } + + private static Object getOrCreateOriginalLayer(LivingEntityRenderState renderState) throws ReflectiveOperationException { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.getEntityRenderDispatcher() != null) { + if (!(client.getEntityRenderDispatcher() instanceof EntityRenderManagerAccessor accessor)) { + return null; + } else { + EntityRenderer renderer = accessor.afw$getRenderer(renderState); + if (renderer == null) { + return null; + } + + Object cached = GENDER_LAYERS.get(renderer); + if (cached != null) { + return cached; + } + + Constructor constructor = null; + + for (Constructor candidate : genderLayerClass.getConstructors()) { + if (candidate.getParameterCount() == 1) { + Class parameter = candidate.getParameterTypes()[0]; + if (parameter.isAssignableFrom(renderer.getClass())) { + candidate.setAccessible(true); + constructor = candidate; + break; + } + } + } + + if (constructor == null) { + return null; + } + + Object created = constructor.newInstance(renderer); + GENDER_LAYERS.put(renderer, created); + return created; + } + } else { + return null; + } + } + + @Nullable + private static WildfireGenderCompat.RenderPoseSnapshot neutralizeRenderPose(LivingEntityRenderState renderState) { + if (renderState == null) { + return null; + } + + WildfireGenderCompat.RenderPoseSnapshot snapshot = new WildfireGenderCompat.RenderPoseSnapshot( + renderState.pose, renderState instanceof BipedEntityRenderState biped && biped.isInSneakingPose + ); + renderState.pose = EntityPose.STANDING; + if (renderState instanceof BipedEntityRenderState biped) { + biped.isInSneakingPose = false; + } + + return snapshot; + } + + private static void restoreRenderPose(LivingEntityRenderState renderState, @Nullable WildfireGenderCompat.RenderPoseSnapshot snapshot) { + if (renderState != null && snapshot != null) { + renderState.pose = snapshot.pose(); + if (renderState instanceof BipedEntityRenderState biped) { + biped.isInSneakingPose = snapshot.sneaking(); + } + } + } + + private static WildfireGenderCompat.BodyTransformSnapshot neutralizeContextBodyTransform(Object layer) throws IllegalAccessException { + Object body = getContextBody(layer); + if (body != null + && bodyOriginXField != null + && bodyOriginYField != null + && bodyOriginZField != null + && bodyPitchField != null + && bodyYawField != null + && bodyRollField != null) { + WildfireGenderCompat.BodyTransformSnapshot snapshot = new WildfireGenderCompat.BodyTransformSnapshot( + body, + bodyOriginXField.getFloat(body), + bodyOriginYField.getFloat(body), + bodyOriginZField.getFloat(body), + bodyPitchField.getFloat(body), + bodyYawField.getFloat(body), + bodyRollField.getFloat(body) + ); + bodyOriginXField.setFloat(body, 0.0F); + bodyOriginYField.setFloat(body, 0.0F); + bodyOriginZField.setFloat(body, 0.0F); + bodyPitchField.setFloat(body, 0.0F); + bodyYawField.setFloat(body, 0.0F); + bodyRollField.setFloat(body, 0.0F); + return snapshot; + } else { + return null; + } + } + + private static void restoreContextBodyTransform(WildfireGenderCompat.BodyTransformSnapshot snapshot) throws IllegalAccessException { + if (snapshot != null) { + bodyOriginXField.setFloat(snapshot.body(), snapshot.originX()); + bodyOriginYField.setFloat(snapshot.body(), snapshot.originY()); + bodyOriginZField.setFloat(snapshot.body(), snapshot.originZ()); + bodyPitchField.setFloat(snapshot.body(), snapshot.pitch()); + bodyYawField.setFloat(snapshot.body(), snapshot.yaw()); + bodyRollField.setFloat(snapshot.body(), snapshot.roll()); + } + } + + private static Object getContextBody(Object layer) { + try { + if (contextModelMethod == null) { + contextModelMethod = findNoArgMethod(layer.getClass(), "getContextModel", "method_17165"); + } + + if (contextModelMethod == null) { + return null; + } + + Object model = contextModelMethod.invoke(layer); + if (model == null) { + return null; + } + + if (modelBodyField == null) { + modelBodyField = findField(model.getClass(), "body", "field_3391"); + } + + if (modelBodyField == null) { + return null; + } + + Object body = modelBodyField.get(model); + if (body == null) { + return null; + } + + Class bodyClass = body.getClass(); + if (bodyOriginXField == null) { + bodyOriginXField = findField(bodyClass, "originX", "pivotX", "field_3657"); + } + + if (bodyOriginYField == null) { + bodyOriginYField = findField(bodyClass, "originY", "pivotY", "field_3656"); + } + + if (bodyOriginZField == null) { + bodyOriginZField = findField(bodyClass, "originZ", "pivotZ", "field_3655"); + } + + if (bodyPitchField == null) { + bodyPitchField = findField(bodyClass, "pitch", "xRot", "field_3674"); + } + + if (bodyYawField == null) { + bodyYawField = findField(bodyClass, "yaw", "yRot", "field_3675"); + } + + if (bodyRollField == null) { + bodyRollField = findField(bodyClass, "roll", "zRot", "field_3654"); + } + + return body; + } catch (ReflectiveOperationException | RuntimeException e) { + logFailure("Failed to neutralize Female Gender vanilla body transform.", e); + return null; + } + } + + private static WildfireGenderState.Side readSide( + Object physics, Object baseUv, Object overlayUv, WildfireGenderState.UvRect defaultBase, WildfireGenderState.UvRect defaultOverlay + ) throws ReflectiveOperationException { + return new WildfireGenderState.Side( + invokeFloat(physicsPositionXMethod, physics, 0.0F), + invokeFloat(physicsPositionYMethod, physics, 0.0F), + invokeFloat(physicsBreastSizeMethod, physics, 0.0F), + invokeFloat(physicsBounceRotationMethod, physics, 0.0F), + readNorthUv(baseUv, defaultBase), + readNorthUv(overlayUv, defaultOverlay) + ); + } + + private static WildfireGenderState.UvRect readNorthUv(Object uvLayout, WildfireGenderState.UvRect fallback) throws ReflectiveOperationException { + if (uvLayout != null && uvGetAllSidesMethod != null) { + if (uvGetAllSidesMethod.invoke(uvLayout) instanceof Map map) { + for (Entry entry : map.entrySet()) { + Object direction = entry.getKey(); + Object quad = entry.getValue(); + if (direction != null && quad != null && "NORTH".equals(String.valueOf(direction))) { + int x1 = (Integer)uvQuadX1Method.invoke(quad); + int y1 = (Integer)uvQuadY1Method.invoke(quad); + int x2 = (Integer)uvQuadX2Method.invoke(quad); + int y2 = (Integer)uvQuadY2Method.invoke(quad); + return WildfireGenderState.UvRect.pixels(x1, y1, x2, y2); + } + } + + return fallback; + } else { + return fallback; + } + } else { + return fallback; + } + } + + private static boolean canHaveBreasts(Object gender) throws ReflectiveOperationException { + if (gender == null) { + return false; + } else { + return genderCanHaveBreasts != null && genderCanHaveBreasts.invoke(gender) instanceof Boolean bool ? bool : !"MALE".equals(String.valueOf(gender)); + } + } + + private static boolean isAvailable() { + if (initialized) { + return available; + } + + initialized = true; + if (!FabricLoader.getInstance().isModLoaded("wildfire_gender")) { + available = false; + return false; + } + + try { + Class renderStateClass = Class.forName("com.wildfire.render.GenderRenderState"); + genderLayerClass = Class.forName("com.wildfire.render.GenderLayer"); + getRenderState = findStaticMethod(renderStateClass, "get", 1); + genderLayerSubmitMethod = findInstanceMethod(genderLayerClass, "submit", 6); + genderField = field(renderStateClass, "gender"); + bustSizeField = field(renderStateClass, "bustSize"); + armorPhysicsOverrideField = field(renderStateClass, "armorPhysicsOverride"); + showBreastsInArmorField = field(renderStateClass, "showBreastsInArmor"); + hasJacketLayerField = field(renderStateClass, "hasJacketLayer"); + breastsField = field(renderStateClass, "breasts"); + leftPhysicsField = field(renderStateClass, "leftBreastPhysics"); + rightPhysicsField = field(renderStateClass, "rightBreastPhysics"); + leftUvField = field(renderStateClass, "leftBreastUVLayout"); + rightUvField = field(renderStateClass, "rightBreastUVLayout"); + leftOverlayUvField = field(renderStateClass, "leftBreastOverlayUVLayout"); + rightOverlayUvField = field(renderStateClass, "rightBreastOverlayUVLayout"); + Class genderClass = Class.forName("com.wildfire.main.config.enums.Gender"); + genderCanHaveBreasts = methodOrNull(genderClass, "canHaveBreasts"); + Class breastStateClass = Class.forName("com.wildfire.render.GenderRenderState$BreastState"); + breastXOffsetField = field(breastStateClass, "xOffset"); + breastYOffsetField = field(breastStateClass, "yOffset"); + breastZOffsetField = field(breastStateClass, "zOffset"); + breastCleavageField = field(breastStateClass, "cleavage"); + breastUniboobField = field(breastStateClass, "uniboob"); + Class physicsClass = Class.forName("com.wildfire.render.GenderRenderState$BreastPhysicsState"); + physicsPositionXMethod = methodOrNull(physicsClass, "getPositionX"); + physicsPositionYMethod = methodOrNull(physicsClass, "getPositionY"); + physicsBreastSizeMethod = methodOrNull(physicsClass, "getBreastSize"); + physicsBounceRotationMethod = methodOrNull(physicsClass, "getBounceRotation"); + physicsPrePositionXField = declaredField(physicsClass, "prePositionX"); + physicsPositionXField = declaredField(physicsClass, "positionX"); + physicsPrePositionYField = declaredField(physicsClass, "prePositionY"); + physicsPositionYField = declaredField(physicsClass, "positionY"); + physicsPreBounceRotationField = declaredField(physicsClass, "preBounceRotation"); + physicsBounceRotationField = declaredField(physicsClass, "bounceRotation"); + Class entityConfigClass = Class.forName("com.wildfire.main.entitydata.EntityConfig"); + entityConfigGetEntityMethod = findStaticMethod(entityConfigClass, "getEntity", 1); + Class breastPhysicsClass = Class.forName("com.wildfire.physics.BreastPhysics"); + breastPhysicsConstructor = breastPhysicsClass.getConstructor(entityConfigClass); + breastPhysicsConstructor.setAccessible(true); + breastPhysicsFinishTickMethod = declaredMethod(breastPhysicsClass, "finishTick"); + breastPhysicsTargetBounceXField = declaredField(breastPhysicsClass, "targetBounceX"); + breastPhysicsTargetBounceYField = declaredField(breastPhysicsClass, "targetBounceY"); + breastPhysicsTargetRotVelField = declaredField(breastPhysicsClass, "targetRotVel"); + breastPhysicsPrePositionXField = declaredField(breastPhysicsClass, "prePositionX"); + breastPhysicsPositionXField = declaredField(breastPhysicsClass, "positionX"); + breastPhysicsPrePositionYField = declaredField(breastPhysicsClass, "prePositionY"); + breastPhysicsPositionYField = declaredField(breastPhysicsClass, "positionY"); + breastPhysicsPreBounceRotationField = declaredField(breastPhysicsClass, "wfg_preBounceRotation"); + breastPhysicsBounceRotationField = declaredField(breastPhysicsClass, "wfg_bounceRotation"); + Class uvLayoutClass = Class.forName("com.wildfire.main.uvs.UVLayout"); + uvGetAllSidesMethod = methodOrNull(uvLayoutClass, "getAllSides"); + Class uvQuadClass = Class.forName("com.wildfire.main.uvs.UVQuad"); + uvQuadX1Method = methodOrNull(uvQuadClass, "x1"); + uvQuadY1Method = methodOrNull(uvQuadClass, "y1"); + uvQuadX2Method = methodOrNull(uvQuadClass, "x2"); + uvQuadY2Method = methodOrNull(uvQuadClass, "y2"); + available = getRenderState != null + && genderLayerSubmitMethod != null + && entityConfigGetEntityMethod != null + && breastPhysicsConstructor != null + && breastPhysicsFinishTickMethod != null + && breastPhysicsTargetBounceXField != null + && breastPhysicsTargetBounceYField != null + && breastPhysicsTargetRotVelField != null + && breastPhysicsPreBounceRotationField != null + && breastPhysicsBounceRotationField != null; + } catch (ReflectiveOperationException | RuntimeException e) { + available = false; + logFailure("Female Gender compatibility disabled: unsupported API shape.", e); + } + + return available; + } + + private static Field field(Class owner, String name) throws NoSuchFieldException { + Field field = owner.getField(name); + field.setAccessible(true); + return field; + } + + private static Field declaredField(Class owner, String name) throws NoSuchFieldException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private static Method declaredMethod(Class owner, String name) throws NoSuchMethodException { + Method method = owner.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } + + private static Field findField(Class owner, String... names) { + for (Class current = owner; current != null && current != Object.class; current = current.getSuperclass()) { + for (String name : names) { + try { + Field field = current.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException var8) { + } + } + } + + return null; + } + + private static Method findStaticMethod(Class owner, String name, int parameterCount) { + for (Method method : owner.getMethods()) { + if (method.getName().equals(name) && method.getParameterCount() == parameterCount && Modifier.isStatic(method.getModifiers())) { + method.setAccessible(true); + return method; + } + } + + return null; + } + + private static Method findInstanceMethod(Class owner, String name, int parameterCount) { + for (Method method : owner.getMethods()) { + if (method.getName().equals(name) && method.getParameterCount() == parameterCount && !Modifier.isStatic(method.getModifiers())) { + method.setAccessible(true); + return method; + } + } + + return null; + } + + private static Method methodOrNull(Class owner, String name) { + try { + Method method = owner.getMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException e) { + return null; + } + } + + private static Method findNoArgMethod(Class owner, String... names) { + for (Class current = owner; current != null && current != Object.class; current = current.getSuperclass()) { + for (Method method : current.getDeclaredMethods()) { + if (method.getParameterCount() == 0 && !Modifier.isStatic(method.getModifiers())) { + for (String name : names) { + if (method.getName().equals(name)) { + method.setAccessible(true); + return method; + } + } + } + } + } + + return null; + } + + private static float invokeFloat(Method method, Object target, float fallback) throws ReflectiveOperationException { + if (method != null && target != null) { + return method.invoke(target) instanceof Number number ? number.floatValue() : fallback; + } else { + return fallback; + } + } + + private static float getFloat(Field field, Object target, float fallback) throws IllegalAccessException { + return field != null && target != null ? field.getFloat(target) : fallback; + } + + private static boolean getBoolean(Field field, Object target, boolean fallback) throws IllegalAccessException { + return field != null && target != null ? field.getBoolean(target) : fallback; + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + private static float shapedImpulse(float value, float reference, float min, float max, float exponent) { + if (Math.abs(value) < 1.0E-6F) { + return 0.0F; + } + + float magnitude = Math.min(Math.abs(value) / Math.max(reference, 1.0E-6F), 1.0F); + float shaped = (float)Math.pow(magnitude, exponent); + return Math.signum(value) * (value < 0.0F ? Math.abs(min) : max) * shaped; + } + + private static float signedYawDelta(Vec3d previousForward, Vec3d currentForward) { + if (previousForward != null && currentForward != null) { + Vec3d a = new Vec3d(previousForward.x, 0.0, previousForward.z); + Vec3d b = new Vec3d(currentForward.x, 0.0, currentForward.z); + if (!(a.lengthSquared() < 1.0E-8) && !(b.lengthSquared() < 1.0E-8)) { + a = a.normalize(); + b = b.normalize(); + double dot = MathHelper.clamp(a.dotProduct(b), -1.0, 1.0); + double crossY = a.z * b.x - a.x * b.z; + return (float)Math.toDegrees(Math.atan2(crossY, dot)); + } else { + return 0.0F; + } + } else { + return 0.0F; + } + } + + private static void logFailure(String message, Throwable error) { + if (!loggedFailure) { + loggedFailure = true; + AnimationEngine.LOGGER.warn("[NoN Animation] {}", message, error); + } + } + + private static final class AnimationPhysicsPair { + private final Object config; + private final Object left; + private final Object right; + private long lastTick = Long.MIN_VALUE; + private WildfireGenderCompat.BonePhysicsSample lastSample; + private WildfireGenderCompat.SideRenderBias previousLeftBias = WildfireGenderCompat.SideRenderBias.ZERO; + private WildfireGenderCompat.SideRenderBias leftBias = WildfireGenderCompat.SideRenderBias.ZERO; + private WildfireGenderCompat.SideRenderBias previousRightBias = WildfireGenderCompat.SideRenderBias.ZERO; + private WildfireGenderCompat.SideRenderBias rightBias = WildfireGenderCompat.SideRenderBias.ZERO; + private boolean hasSmoothedTarget; + private float smoothedTargetX; + private float smoothedTargetY; + private float smoothedTargetRotation; + + private AnimationPhysicsPair(Object config, Object left, Object right) { + this.config = config; + this.left = left; + this.right = right; + } + + private void stepRenderBias(WildfireGenderCompat.GravityBias gravity, boolean uniboob) { + this.previousLeftBias = this.leftBias; + this.previousRightBias = this.rightBias; + float x = WildfireGenderCompat.clamp(gravity.sidePull() * 0.45F, -0.8F, 0.8F); + float y = WildfireGenderCompat.clamp(gravity.forwardDown() * 0.85F, 0.0F, 1.2F); + float rotation = WildfireGenderCompat.clamp(gravity.sidePull() * 10.0F + gravity.forwardPull() * 2.0F, -10.0F, 10.0F); + this.leftBias = new WildfireGenderCompat.SideRenderBias(x, y, uniboob ? 0.0F : rotation); + this.rightBias = uniboob ? this.leftBias : this.leftBias.mirrored(); + } + + private WildfireGenderCompat.TargetImpulse smoothTarget(float x, float y, float rotation) { + if (!this.hasSmoothedTarget) { + this.hasSmoothedTarget = true; + this.smoothedTargetX = x; + this.smoothedTargetY = y; + this.smoothedTargetRotation = rotation; + return new WildfireGenderCompat.TargetImpulse(x, y, rotation); + } else { + this.smoothedTargetX = MathHelper.lerp(0.1F, this.smoothedTargetX, x); + this.smoothedTargetY = MathHelper.lerp(0.1F, this.smoothedTargetY, y); + this.smoothedTargetRotation = MathHelper.lerp(0.1F, this.smoothedTargetRotation, rotation); + return new WildfireGenderCompat.TargetImpulse(this.smoothedTargetX, this.smoothedTargetY, this.smoothedTargetRotation); + } + } + } + + private record BodyTransformSnapshot(Object body, float originX, float originY, float originZ, float pitch, float yaw, float roll) { + } + + private record BonePhysicsSample(float x, float y, float z, float chestX, float chestY, float chestZ, Vec3d forward, Vec3d right, Vec3d up) { + static WildfireGenderCompat.BonePhysicsSample from(MatrixStack rootPose, MatrixStack bonePose) { + Matrix4f relativePose = new Matrix4f(rootPose.peek().getPositionMatrix()).invert().mul(new Matrix4f(bonePose.peek().getPositionMatrix())); + Vector3f origin = relativePose.transformPosition(0.0F, 0.0F, 0.0F, new Vector3f()); + Vector3f forwardPoint = relativePose.transformPosition(0.0F, 0.0F, -1.0F, new Vector3f()); + Vector3f rightPoint = relativePose.transformPosition(1.0F, 0.0F, 0.0F, new Vector3f()); + Vector3f upPoint = relativePose.transformPosition(0.0F, 1.0F, 0.0F, new Vector3f()); + Vector3f chestPoint = relativePose.transformPosition(0.0F, 0.3F, -0.35F, new Vector3f()); + Vec3d forward = normalize(new Vec3d(forwardPoint.x() - origin.x(), forwardPoint.y() - origin.y(), forwardPoint.z() - origin.z())); + Vec3d right = normalize(new Vec3d(rightPoint.x() - origin.x(), rightPoint.y() - origin.y(), rightPoint.z() - origin.z())); + Vec3d up = normalize(new Vec3d(upPoint.x() - origin.x(), upPoint.y() - origin.y(), upPoint.z() - origin.z())); + return forward != null && right != null && up != null + ? new WildfireGenderCompat.BonePhysicsSample(origin.x(), origin.y(), origin.z(), chestPoint.x(), chestPoint.y(), chestPoint.z(), forward, right, up) + : null; + } + + private static Vec3d normalize(Vec3d vector) { + if (vector != null && !(vector.lengthSquared() < 1.0E-8)) { + Vec3d normalized = vector.normalize(); + return normalized.isFinite() ? normalized : null; + } else { + return null; + } + } + } + + private record GravityBias(float sidePull, float forwardPull, float forwardDown, float tiltInfluence) { + private static final Vec3d MODEL_DOWN = new Vec3d(0.0, -1.0, 0.0); + + static WildfireGenderCompat.GravityBias from(WildfireGenderCompat.BonePhysicsSample sample) { + if (sample == null) { + return new WildfireGenderCompat.GravityBias(0.0F, 0.0F, 0.0F, 0.0F); + } + + float sidePull = (float)sample.right().dotProduct(MODEL_DOWN); + float forwardPull = (float)sample.forward().dotProduct(MODEL_DOWN); + float forwardDown = Math.abs(forwardPull); + float upsideDown = Math.max(0.0F, (float)sample.up().dotProduct(MODEL_DOWN)); + float tiltInfluence = WildfireGenderCompat.clamp(Math.max(Math.abs(sidePull), Math.max(forwardDown, upsideDown)), 0.0F, 1.0F); + return new WildfireGenderCompat.GravityBias(sidePull, forwardPull, forwardDown, tiltInfluence); + } + } + + private record RenderPoseSnapshot(EntityPose pose, boolean sneaking) { + } + + private record SideRenderBias(float x, float y, float rotation) { + private static final WildfireGenderCompat.SideRenderBias ZERO = new WildfireGenderCompat.SideRenderBias(0.0F, 0.0F, 0.0F); + + private WildfireGenderCompat.SideRenderBias mirrored() { + return new WildfireGenderCompat.SideRenderBias(-this.x * 0.85F, this.y * 0.95F, -this.rotation * 0.85F); + } + } + + private record TargetImpulse(float x, float y, float rotation) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderState.java b/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderState.java new file mode 100644 index 0000000..166a87a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/compat/wildfire/WildfireGenderState.java @@ -0,0 +1,24 @@ +package com.nonid.internal.animation.client.compat.wildfire; + +public record WildfireGenderState( + float bustSize, + float xOffset, + float yOffset, + float zOffset, + float cleavage, + boolean uniboob, + boolean hasJacketLayer, + WildfireGenderState.Side left, + WildfireGenderState.Side right +) { + public record Side( + float physicsX, float physicsY, float physicsSize, float bounceRotation, WildfireGenderState.UvRect baseUv, WildfireGenderState.UvRect overlayUv + ) { + } + + public record UvRect(float u1, float v1, float u2, float v2) { + public static WildfireGenderState.UvRect pixels(int x1, int y1, int x2, int y2) { + return new WildfireGenderState.UvRect(x1 / 64.0F, y1 / 64.0F, x2 / 64.0F, y2 / 64.0F); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/diagnostics/AnimationAssetDiagnostics.java b/src/main/java/com/nonid/internal/animation/client/diagnostics/AnimationAssetDiagnostics.java new file mode 100644 index 0000000..54f3f8e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/diagnostics/AnimationAssetDiagnostics.java @@ -0,0 +1,391 @@ +package com.nonid.internal.animation.client.diagnostics; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.nonid.NonDebugChatCategory; +import com.nonid.client.NonClientDiagnostics; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.client.render.gecko.GeckoResourceResolver; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AnimationStageInfo; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationAssetDiagnostics { + private static final Identifier RELOADER_ID = AnimationEngine.runtimeId("asset_diagnostics"); + private static final double LENGTH_WARNING_TOLERANCE_SECONDS = 0.05; + private static final Pattern CONJOINED_ACTOR_KEY = Pattern.compile("^(.+\\.p\\d+)_(.+)$"); + private static volatile Map> LENGTHS_BY_RESOURCE = Map.of(); + private static volatile List RESOURCE_WARNINGS = List.of(); + private static final Set WARNED_MISMATCHES = new LinkedHashSet<>(); + private static final Set WARNED_RESOURCE_CHAT = new LinkedHashSet<>(); + private static long reloadRevision = 0L; + private static String lastDefinitionValidationKey = ""; + + private AnimationAssetDiagnostics() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES).registerReloader(RELOADER_ID, new AnimationAssetDiagnostics.Reloader()); + } + + public static void validateLoadedDefinitionsOnce() { + sendResourceWarningsToChat(); + List definitions = AnimationDefinitions.getLoadedDefinitionsSnapshot(); + if (!definitions.isEmpty() && !LENGTHS_BY_RESOURCE.isEmpty()) { + String validationKey = buildDefinitionValidationKey(definitions); + if (!validationKey.equals(lastDefinitionValidationKey)) { + lastDefinitionValidationKey = validationKey; + + for (AnimationDefinitions.Definition definition : definitions) { + validateDefinition(definition); + } + } + } + } + + public static void validateStartPayload(Identifier animationId, List actorKeys, List stages) { + if (animationId != null && stages != null && !stages.isEmpty() && !LENGTHS_BY_RESOURCE.isEmpty()) { + List safeActorKeys = actorKeys == null ? List.of() : actorKeys; + + for (AnimationStageInfo stage : stages) { + if (stage != null) { + if (safeActorKeys.isEmpty()) { + validateStageResource(animationId, stage, null, List.of()); + } else { + for (String actorKey : safeActorKeys) { + validateStageResource(animationId, stage, actorKey, List.of()); + } + } + } + } + } + } + + private static void validateDefinition(AnimationDefinitions.Definition definition) { + if (definition != null && !definition.stages().isEmpty()) { + List actorKeys = AnimationDefinitions.resolveActorKeys(definition); + List actors = definition.actors(); + + for (AnimationStageInfo stage : definition.stages()) { + if (stage != null) { + int count = Math.max(actorKeys.size(), actors.size()); + if (count == 0) { + validateStageResource(definition.id(), stage, null, List.of()); + } else { + for (int i = 0; i < count; i++) { + String actorKey = i < actorKeys.size() ? actorKeys.get(i) : null; + AnimationDefinitions.ActorConstraint actor = i < actors.size() ? actors.get(i) : null; + List entityTypes = actor == null ? List.of() : sortedEntityTypes(actor.entityTypes()); + validateStageResource(definition.id(), stage, actorKey, entityTypes); + } + } + } + } + } + } + + private static void validateStageResource(Identifier definitionId, AnimationStageInfo stage, @Nullable String actorKey, List entityTypes) { + if (definitionId != null && stage != null && stage.lengthTicks() > 0L) { + Identifier playbackId = stage.effectiveAnimationId(); + if (playbackId != null) { + if (entityTypes != null && !entityTypes.isEmpty()) { + for (Identifier entityType : entityTypes) { + if (entityType != null) { + validateResolvedResource(definitionId, stage, actorKey, entityType); + } + } + } else { + validateResolvedResource(definitionId, stage, actorKey, null); + } + } + } + } + + private static void validateResolvedResource(Identifier definitionId, AnimationStageInfo stage, @Nullable String actorKey, @Nullable Identifier entityType) { + Identifier playbackId = stage.effectiveAnimationId(); + Identifier animationResource = GeckoResourceResolver.resolveAnimationResource(playbackId, actorKey, entityType, false); + if (animationResource != null) { + String animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource, playbackId, actorKey, entityType, false); + if (animationKey != null) { + validateIfPresent(definitionId, stage, actorKey, animationResource, animationKey); + } + } + } + + private static boolean validateIfPresent( + Identifier definitionId, AnimationStageInfo stage, @Nullable String actorKey, @Nullable Identifier animationResource, @Nullable String animationKey + ) { + if (animationResource != null && animationKey != null) { + Map resourceAnimations = LENGTHS_BY_RESOURCE.get(animationResource); + AnimationAssetDiagnostics.AnimationAssetLength asset = resourceAnimations == null ? null : resourceAnimations.get(animationKey); + if (asset != null && !(asset.lengthSeconds() <= 0.0)) { + double stageSeconds = stageCycleSeconds(stage); + double delta = Math.abs(stageSeconds - asset.lengthSeconds()); + if (delta > 0.05) { + warnMismatch(definitionId, stage, actorKey, animationResource, asset, stageSeconds); + } + + return true; + } else { + return false; + } + } else { + return false; + } + } + + private static void warnMismatch( + Identifier definitionId, + AnimationStageInfo stage, + @Nullable String actorKey, + Identifier animationResource, + AnimationAssetDiagnostics.AnimationAssetLength asset, + double stageSeconds + ) { + String key = definitionId + "|" + stage.animationId() + "|" + actorKey + "|" + animationResource + "|" + asset.animationKey(); + if (WARNED_MISMATCHES.add(key)) { + String actorText = actorKey != null && !actorKey.isBlank() ? "actor '" + actorKey + "'" : "base"; + String playbackText = stage.animationId().equals(stage.effectiveAnimationId()) ? "" : " (plays " + stage.effectiveAnimationId() + ")"; + String message = String.format( + Locale.ROOT, + "%s stage %s%s declares cycle_seconds=%ss, but %s animation %s key '%s' has animation_length=%ss. cycle_seconds should match the authored GeckoLib clip length; use stage speed to slow/accelerate playback.", + definitionId, + stage.animationId(), + playbackText, + formatSeconds(stageSeconds), + actorText, + animationResource, + asset.animationKey(), + formatSeconds(asset.lengthSeconds()) + ); + AnimationEngine.LOGGER.warn("[NoN Animation] Setup warning: {}", message); + sendSetupChatWarning(message); + } + } + + private static void sendSetupChatWarning(String message) { + if (message != null && !message.isBlank()) { + NonClientDiagnostics.send(NonDebugChatCategory.SETUP, Text.literal("[NoN Animation] Setup warning: " + message), false); + } + } + + private static void sendResourceWarningsToChat() { + for (String warning : RESOURCE_WARNINGS) { + if (warning != null && WARNED_RESOURCE_CHAT.add(warning)) { + sendSetupChatWarning(warning); + } + } + } + + private static List sortedEntityTypes(Set entityTypes) { + if (entityTypes != null && !entityTypes.isEmpty()) { + List out = new ArrayList<>(entityTypes); + out.sort(Comparator.comparing(Identifier::toString)); + return List.copyOf(out); + } else { + return List.of(); + } + } + + private static String buildDefinitionValidationKey(List definitions) { + StringBuilder sb = new StringBuilder(); + sb.append(reloadRevision); + + for (AnimationDefinitions.Definition definition : definitions) { + if (definition != null) { + sb.append('|').append(definition.id()); + + for (AnimationStageInfo stage : definition.stages()) { + if (stage != null) { + sb.append(':') + .append(stage.animationId()) + .append("->") + .append(stage.effectiveAnimationId()) + .append('@') + .append(stage.lengthTicks()) + .append('~') + .append(stage.cycleTicks()) + .append('/') + .append(stage.cycleMidpointOffsetSeconds()); + } + } + } + } + + return sb.toString(); + } + + private static void reload(ResourceManager manager) { + Map resources = manager.findResources("geckolib/animations", id -> id.getPath().endsWith(".animation.json")); + List> entries = new ArrayList<>(resources.entrySet()); + entries.sort(Comparator.comparing(ex -> ((Identifier)ex.getKey()).toString())); + Map> loaded = new LinkedHashMap<>(); + List resourceWarnings = new ArrayList<>(GeckoModelCopyDiagnostics.scan(manager)); + + for (String warning : resourceWarnings) { + AnimationEngine.LOGGER.warn("[NoN Animation] Setup warning: {}", warning); + } + + for (Entry entry : entries) { + Identifier fileId = entry.getKey(); + Identifier animationResource = animationIdFromPath(fileId); + if (animationResource != null) { + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + JsonObject animationsObj = root.has("animations") && root.get("animations").isJsonObject() ? root.getAsJsonObject("animations") : null; + if (animationsObj != null && !animationsObj.isEmpty()) { + Map resourceLengths = new LinkedHashMap<>(); + Map> soundOwnersByStage = new LinkedHashMap<>(); + + for (Entry animationEntry : animationsObj.entrySet()) { + String animationKey = animationEntry.getKey(); + JsonElement animationEl = animationEntry.getValue(); + if (animationKey != null && !animationKey.isBlank() && animationEl != null && animationEl.isJsonObject()) { + JsonObject animationObj = animationEl.getAsJsonObject(); + double lengthSeconds = readAnimationLength(animationObj); + if (lengthSeconds > 0.0) { + resourceLengths.put(animationKey, new AnimationAssetDiagnostics.AnimationAssetLength(animationKey, lengthSeconds)); + } + + Matcher actorKeyMatcher = CONJOINED_ACTOR_KEY.matcher(animationKey); + if (actorKeyMatcher.matches() && hasNonEmptyObject(animationObj, "sound_effects")) { + soundOwnersByStage.computeIfAbsent(actorKeyMatcher.group(1), ignored -> new ArrayList<>()).add(animationKey); + } + } + } + + if (!resourceLengths.isEmpty()) { + loaded.put(animationResource, Map.copyOf(resourceLengths)); + } + + for (Entry> owners : soundOwnersByStage.entrySet()) { + if (owners.getValue().size() > 1) { + String warning = "Conjoined animation " + + animationResource + + " stage '" + + owners.getKey() + + "' contains sound_effects on multiple actor clips " + + owners.getValue() + + ". Keep the sound track on exactly one actor clip."; + resourceWarnings.add(warning); + AnimationEngine.LOGGER.warn("[NoN Animation] Setup warning: {}", warning); + } + } + } + } catch (IOException | RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to read animation length from {}", fileId, e); + } + } + } + + LENGTHS_BY_RESOURCE = Map.copyOf(loaded); + RESOURCE_WARNINGS = List.copyOf(resourceWarnings); + WARNED_MISMATCHES.clear(); + WARNED_RESOURCE_CHAT.clear(); + lastDefinitionValidationKey = ""; + reloadRevision++; + } + + private static double stageCycleSeconds(AnimationStageInfo stage) { + if (stage == null) { + return 0.0; + } else { + double exactTicks = stage.cycleTicks(); + if (Double.isFinite(exactTicks) && exactTicks > 0.0) { + return exactTicks / 20.0; + } else { + return stage.lengthTicks() > 0L ? stage.lengthTicks() / 20.0 : 0.0; + } + } + } + + private static double readAnimationLength(JsonObject animObj) { + Double animationLength = readFiniteDouble(animObj.get("animation_length")); + if (animationLength != null) { + return animationLength; + } + + Double length = readFiniteDouble(animObj.get("length")); + return length != null ? length : -1.0; + } + + private static boolean hasNonEmptyObject(JsonObject object, String key) { + return object != null && key != null && object.has(key) && object.get(key).isJsonObject() ? !object.getAsJsonObject(key).isEmpty() : false; + } + + private static Double readFiniteDouble(JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + return Double.isFinite(value) ? value : null; + } + + if (!primitive.isString()) { + return null; + } + + String raw = primitive.getAsString(); + if (raw != null && !raw.isBlank()) { + try { + double value = Double.parseDouble(raw.trim()); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException ignored) { + return null; + } + } else { + return null; + } + } else { + return null; + } + } + + private static Identifier animationIdFromPath(Identifier fileId) { + String path = fileId.getPath(); + String prefix = "geckolib/animations/"; + String suffix = ".animation.json"; + if (path.startsWith(prefix) && path.endsWith(suffix)) { + String animPath = path.substring(prefix.length(), path.length() - suffix.length()); + return Identifier.of(fileId.getNamespace(), animPath); + } else { + return null; + } + } + + private static String formatSeconds(double seconds) { + return String.format(Locale.ROOT, "%.3f", seconds); + } + + private record AnimationAssetLength(String animationKey, double lengthSeconds) { + } + + public static final class Reloader implements SynchronousResourceReloader { + public void reload(ResourceManager manager) { + AnimationAssetDiagnostics.reload(manager); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/diagnostics/GeckoModelCopyDiagnostics.java b/src/main/java/com/nonid/internal/animation/client/diagnostics/GeckoModelCopyDiagnostics.java new file mode 100644 index 0000000..ef20b47 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/diagnostics/GeckoModelCopyDiagnostics.java @@ -0,0 +1,277 @@ +package com.nonid.internal.animation.client.diagnostics; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nonid.internal.animation.AnimationEngine; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import net.minecraft.resource.InputSupplier; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourcePack; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +final class GeckoModelCopyDiagnostics { + private static final Identifier DEFAULT_PACK_ID = Identifier.of("needsofnature", "default_pack"); + private static final String MODEL_SCAN_PREFIX = "geckolib/models"; + private static final String SHARED_ENTITY_PREFIX = "geckolib/models/entity/"; + private static final String LOCAL_ENTITY_PREFIX = "geckolib/models/%s/entity/"; + private static final String GEO_SUFFIX = ".geo.json"; + private static final int MAX_LISTED_MODELS = 8; + + private GeckoModelCopyDiagnostics() { + } + + static List scan(ResourceManager manager) { + if (manager == null) { + return List.of(); + } + + List packs = readPackDescriptors(manager); + List defaultPacks = packs.stream().filter(packx -> DEFAULT_PACK_ID.equals(packx.logicalId())).toList(); + if (defaultPacks.isEmpty()) { + return List.of(); + } + + Map> defaultModels = new LinkedHashMap<>(); + + for (GeckoModelCopyDiagnostics.PackDescriptor defaultPack : defaultPacks) { + for (GeckoModelCopyDiagnostics.ModelFile model : readModels(defaultPack, null)) { + defaultModels.computeIfAbsent(model.location().key(), ignored -> new ArrayList<>()).add(model.json()); + } + } + + if (defaultModels.isEmpty()) { + return List.of(); + } + + Set defaultKeys = Set.copyOf(defaultModels.keySet()); + List warnings = new ArrayList<>(); + + for (GeckoModelCopyDiagnostics.PackDescriptor pack : packs) { + if (!DEFAULT_PACK_ID.equals(pack.logicalId())) { + LinkedHashSet duplicates = new LinkedHashSet<>(); + + for (GeckoModelCopyDiagnostics.ModelFile model : readModels(pack, defaultKeys)) { + List defaults = defaultModels.get(model.location().key()); + if (defaults != null && !defaults.stream().noneMatch(defaultModel -> modelsEqual(defaultModel, model.json()))) { + duplicates.add(model.location().displayName()); + } + } + + if (!duplicates.isEmpty()) { + warnings.add(buildWarning(pack, duplicates)); + } + } + } + + return warnings.isEmpty() ? List.of() : List.copyOf(warnings); + } + + static boolean modelJsonEquals(String left, String right) { + if (left != null && right != null) { + try { + JsonElement leftJson = JsonParser.parseString(left); + JsonElement rightJson = JsonParser.parseString(right); + return modelsEqual(leftJson, rightJson); + } catch (RuntimeException ignored) { + return false; + } + } else { + return false; + } + } + + private static boolean modelsEqual(JsonElement left, JsonElement right) { + return left != null && right != null && left.isJsonObject() && right.isJsonObject() && left.equals(right); + } + + @Nullable + static GeckoModelCopyDiagnostics.ModelLocation classifyModel(Identifier resourceId, @Nullable Identifier logicalPackId) { + if (resourceId != null && resourceId.getPath().endsWith(".geo.json")) { + String path = resourceId.getPath(); + if ("animationframework".equals(resourceId.getNamespace()) && path.startsWith("geckolib/models/entity/")) { + return location(path.substring("geckolib/models/entity/".length()), GeckoModelCopyDiagnostics.ModelScope.SHARED); + } else if (logicalPackId != null && logicalPackId.getNamespace().equals(resourceId.getNamespace())) { + String localPrefix = "geckolib/models/%s/entity/".formatted(logicalPackId.getPath()); + return path.startsWith(localPrefix) ? location(path.substring(localPrefix.length()), GeckoModelCopyDiagnostics.ModelScope.LOCAL) : null; + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static GeckoModelCopyDiagnostics.ModelLocation location(String relativePath, GeckoModelCopyDiagnostics.ModelScope scope) { + if (relativePath != null && relativePath.endsWith(".geo.json")) { + String key = relativePath.substring(0, relativePath.length() - ".geo.json".length()); + return key.isBlank() ? null : new GeckoModelCopyDiagnostics.ModelLocation(key, scope); + } else { + return null; + } + } + + private static List readPackDescriptors(ResourceManager manager) { + try (Stream stream = manager.streamResourcePacks()) { + return stream.filter(pack -> pack != null).map(GeckoModelCopyDiagnostics::describePack).toList(); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to inspect resource packs for duplicate GeckoLib models.", e); + return List.of(); + } + } + + private static GeckoModelCopyDiagnostics.PackDescriptor describePack(ResourcePack pack) { + String runtimeId = pack.getId() != null && !pack.getId().isBlank() ? pack.getId() : "unknown"; + Identifier logicalId = null; + String name = null; + + try { + InputSupplier supplier = pack.openRoot(new String[]{"pack.mcmeta"}); + if (supplier != null) { + try ( + InputStream input = (InputStream)supplier.get(); + InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8); + ) { + JsonElement parsed = JsonParser.parseReader(reader); + if (parsed.isJsonObject()) { + JsonObject root = parsed.getAsJsonObject(); + JsonElement metadataElement = root.get("animationframework"); + if (metadataElement != null && metadataElement.isJsonObject()) { + JsonObject metadata = metadataElement.getAsJsonObject(); + logicalId = readIdentifier(metadata, "id"); + name = readString(metadata, "name"); + } + } + } + } + } catch (IOException | RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to read pack identity for {}.", runtimeId, e); + } + + return new GeckoModelCopyDiagnostics.PackDescriptor(pack, runtimeId, logicalId, name); + } + + private static List readModels(GeckoModelCopyDiagnostics.PackDescriptor descriptor, @Nullable Set acceptedKeys) { + List models = new ArrayList<>(); + ResourcePack pack = descriptor.pack(); + + try { + List namespaces = new ArrayList<>(pack.getNamespaces(ResourceType.CLIENT_RESOURCES)); + namespaces.sort(String::compareTo); + + for (String namespace : namespaces) { + pack.findResources( + ResourceType.CLIENT_RESOURCES, + namespace, + "geckolib/models", + (resourceId, supplier) -> readModel(descriptor, resourceId, supplier, acceptedKeys, models) + ); + } + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to scan GeckoLib models in {}.", descriptor.runtimeId(), e); + } + + models.sort(Comparator.comparing(model -> model.resourceId().toString())); + return List.copyOf(models); + } + + private static void readModel( + GeckoModelCopyDiagnostics.PackDescriptor descriptor, + Identifier resourceId, + InputSupplier supplier, + @Nullable Set acceptedKeys, + List output + ) { + GeckoModelCopyDiagnostics.ModelLocation location = classifyModel(resourceId, descriptor.logicalId()); + if (location != null && (acceptedKeys == null || acceptedKeys.contains(location.key()))) { + try ( + InputStream input = (InputStream)supplier.get(); + InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8); + ) { + JsonElement json = JsonParser.parseReader(reader); + if (json.isJsonObject()) { + output.add(new GeckoModelCopyDiagnostics.ModelFile(resourceId, location, json)); + } + } catch (IOException | RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to compare GeckoLib model {} from {}.", new Object[]{resourceId, descriptor.runtimeId(), e}); + } + } + } + + private static String buildWarning(GeckoModelCopyDiagnostics.PackDescriptor pack, Set duplicateModels) { + List sorted = duplicateModels.stream().sorted().toList(); + int shown = Math.min(8, sorted.size()); + String models = String.join(", ", sorted.subList(0, shown)); + if (shown < sorted.size()) { + models = models + ", +" + (sorted.size() - shown) + " more"; + } + + return "Pack '" + + pack.displayName() + + "' contains GeckoLib actor models identical to the NoN default pack: " + + models + + ". Remove these unchanged copies and let NoN use the maintained default models through fallback; copied defaults can become incompatible after updates."; + } + + @Nullable + private static Identifier readIdentifier(JsonObject object, String key) { + String raw = readString(object, key); + return raw != null && raw.indexOf(58) > 0 ? Identifier.tryParse(raw) : null; + } + + @Nullable + private static String readString(JsonObject object, String key) { + if (object != null && key != null) { + JsonElement element = object.get(key); + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String value = element.getAsString().trim(); + return value.isEmpty() ? null : value; + } else { + return null; + } + } else { + return null; + } + } + + private record ModelFile(Identifier resourceId, GeckoModelCopyDiagnostics.ModelLocation location, JsonElement json) { + } + + record ModelLocation(String key, GeckoModelCopyDiagnostics.ModelScope scope) { + String displayName() { + return this.key + " (" + this.scope.label + ")"; + } + } + + enum ModelScope { + LOCAL("local"), + SHARED("shared"); + + private final String label; + + ModelScope(String label) { + this.label = label; + } + } + + private record PackDescriptor(ResourcePack pack, String runtimeId, @Nullable Identifier logicalId, @Nullable String name) { + String displayName() { + String identity = this.logicalId == null ? this.runtimeId : this.logicalId.toString(); + return this.name != null && !this.name.equals(identity) ? this.name + " (" + identity + ")" : identity; + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/AnimationRenderStateAccess.java b/src/main/java/com/nonid/internal/animation/client/render/AnimationRenderStateAccess.java new file mode 100644 index 0000000..ea5a95b --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/AnimationRenderStateAccess.java @@ -0,0 +1,14 @@ +package com.nonid.internal.animation.client.render; + +import java.util.UUID; +import net.minecraft.util.Identifier; + +public interface AnimationRenderStateAccess { + UUID afw$getEntityUuid(); + + void afw$setEntityUuid(UUID var1); + + Identifier afw$getEntityTypeId(); + + void afw$setEntityTypeId(Identifier var1); +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorAnimatable.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorAnimatable.java new file mode 100644 index 0000000..014aa68 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorAnimatable.java @@ -0,0 +1,300 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.EnergyHolder; +import com.nonid.NonDebugChatCategory; +import com.nonid.api.client.NonAnimationParticleEvents; +import com.nonid.internal.animation.AnimationEngineClient; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.particle.ParticleType; +import net.minecraft.particle.SimpleParticleType; +import net.minecraft.registry.Registries; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animatable.manager.AnimatableManager.ControllerRegistrar; +import software.bernie.geckolib.animation.AnimationController; +import software.bernie.geckolib.animation.RawAnimation; +import software.bernie.geckolib.animation.object.LoopType; +import software.bernie.geckolib.animation.object.PlayState; +import software.bernie.geckolib.animation.state.AnimationTimeline; +import software.bernie.geckolib.animation.state.KeyFrameEvent; +import software.bernie.geckolib.cache.animation.keyframeevent.ParticleKeyframeData; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.util.GeckoLibUtil; +import org.jetbrains.annotations.Nullable; + +public final class AnimationActorAnimatable implements GeoAnimatable { + public static final AnimationActorAnimatable INSTANCE = new AnimationActorAnimatable(); + private final AnimatableInstanceCache cache = GeckoLibUtil.createInstanceCache(this); + private static final Map LOOP_CACHE = new ConcurrentHashMap<>(); + private static final Map PLAY_ONCE_CACHE = new ConcurrentHashMap<>(); + private static final Set MISSING_ANIMATION_WARNED = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final double PARTICLE_Y_OFFSET = -0.0625; + private static final double LOOP_WRAP_EPSILON_SECONDS = 0.004166666666666667; + private static final LoopType LOOP_NO_ZERO_WRAP = LoopType.register( + "afw_loop_no_zero_wrap", (animatable, animationPoint, timelineStage, renderState, controller) -> { + double wrapped = timelineStage.startTime() + (controller.getCurrentTimelineTime() - timelineStage.endTime()); + double start = timelineStage.startTime(); + double end = timelineStage.endTime(); + if (wrapped <= start) { + wrapped = start + 0.004166666666666667; + } + + if (wrapped >= end) { + wrapped = Math.nextDown(end); + } + + controller.setTimelineTime(wrapped); + return true; + } + ); + + private AnimationActorAnimatable() { + } + + public void registerControllers(ControllerRegistrar controllers) { + AnimationController controller = new AnimationController( + "afw_main", + test -> { + GeoRenderState renderState = test.renderState(); + if (!renderState.hasGeckolibData(GeckoRenderTickets.ANIMATION_ID)) { + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_INSTANCE_ID, null); + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_RESOURCE_ID, null); + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_KEY, null); + return PlayState.STOP; + } + + Identifier animationId = (Identifier)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_ID); + if (animationId == null) { + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_INSTANCE_ID, null); + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_RESOURCE_ID, null); + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_KEY, null); + return PlayState.STOP; + } + + Identifier animationResource = (Identifier)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_RESOURCE_ID); + if (animationResource == null) { + animationResource = GeckoResourceResolver.toAnimationResource(animationId); + } + + if (!GeckoResourceResolver.hasBakedAnimation(animationResource)) { + warnMissingAnimation(animationResource); + return PlayState.STOP; + } + + boolean hasExplicitAnimationKey = renderState.hasGeckolibData(GeckoRenderTickets.ANIMATION_KEY); + String animationKey = hasExplicitAnimationKey ? (String)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_KEY) : null; + if (!hasExplicitAnimationKey) { + animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource); + } + + if (animationKey == null || animationKey.isBlank()) { + return PlayState.STOP; + } + + if (!GeckoResourceResolver.hasBakedAnimationKey(animationResource, animationKey)) { + warnMissingAnimation(animationResource); + return PlayState.STOP; + } + + UUID instanceId = renderState.hasGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID) + ? (UUID)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID) + : null; + UUID lastInstanceId = (UUID)test.manager().getAnimatableData(GeckoRenderTickets.LAST_ANIMATION_INSTANCE_ID); + Identifier lastAnimationResource = (Identifier)test.manager().getAnimatableData(GeckoRenderTickets.LAST_ANIMATION_RESOURCE_ID); + String lastAnimationKey = (String)test.manager().getAnimatableData(GeckoRenderTickets.LAST_ANIMATION_KEY); + boolean reloadController = false; + if (instanceId != null && !instanceId.equals(lastInstanceId)) { + reloadController = true; + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_INSTANCE_ID, instanceId); + } + + if (lastAnimationResource == null || !lastAnimationResource.equals(animationResource)) { + reloadController = true; + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_RESOURCE_ID, animationResource); + } + + if (lastAnimationKey == null || !lastAnimationKey.equals(animationKey)) { + reloadController = true; + test.manager().setAnimatableData(GeckoRenderTickets.LAST_ANIMATION_KEY, animationKey); + } + + if (reloadController) { + test.controller().reset(); + } + + double speed = 1.0; + if (renderState.hasGeckolibData(GeckoRenderTickets.ANIMATION_SPEED)) { + Double s = (Double)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_SPEED); + if (s != null && Double.isFinite(s) && s > 0.0) { + speed = Math.max(0.1, Math.min(4.0, s)); + } + } + + boolean loop = ClientAnimationRuntime.isAnimationLooping(animationId); + RawAnimation toPlay = loop ? loopAnimation(animationKey) : playOnceAnimation(animationKey); + if (!loop && test.isCurrentAnimation(toPlay) && test.controller().hasAnimationFinished()) { + AnimationTimeline timeline = test.controller().getTimeline(); + if (timeline != null) { + double holdTime = Math.nextDown(timeline.lastAnimationEndTime()); + if (Double.isFinite(holdTime) && holdTime >= 0.0) { + test.controller().setTimelineTime(holdTime); + } + } + } + + PlayState playState = test.setAndContinue(toPlay); + test.setControllerSpeed((float)speed); + Double timelineSeconds = (Double)renderState.getGeckolibData(GeckoRenderTickets.ANIMATION_TIMELINE_SECONDS); + if (timelineSeconds != null && Double.isFinite(timelineSeconds) && timelineSeconds >= 0.0) { + test.controller().setTimelineTime(timelineSeconds); + } + + return playState; + } + ); + controller.setSoundKeyframeHandler(event -> {}); + controller.setParticleKeyframeHandler(AnimationActorAnimatable::handleParticleKeyframe); + controllers.add(controller); + } + + @NotNull + public AnimatableInstanceCache getAnimatableInstanceCache() { + return this.cache; + } + + private static RawAnimation loopAnimation(String key) { + return LOOP_CACHE.computeIfAbsent(key, k -> RawAnimation.begin().then(k, LOOP_NO_ZERO_WRAP)); + } + + private static RawAnimation playOnceAnimation(String key) { + return PLAY_ONCE_CACHE.computeIfAbsent(key, k -> RawAnimation.begin().thenPlay(k)); + } + + public static void clearResourceCaches() { + LOOP_CACHE.clear(); + PLAY_ONCE_CACHE.clear(); + MISSING_ANIMATION_WARNED.clear(); + } + + private static void handleParticleKeyframe(KeyFrameEvent event) { + if (event != null && event.keyframeData() != null && event.renderState() != null) { + String effect = ((ParticleKeyframeData)event.keyframeData()).getEffect(); + if (effect != null && !effect.isBlank()) { + Identifier particleId = Identifier.tryParse(effect.trim()); + if (particleId != null && Registries.PARTICLE_TYPE.containsId(particleId)) { + ParticleType type = (ParticleType)Registries.PARTICLE_TYPE.get(particleId); + ParticleEffect particle = type instanceof SimpleParticleType simpleParticle ? simpleParticle : null; + UUID actorUuid = (UUID)event.renderState().getGeckolibData(GeckoRenderTickets.ACTOR_UUID); + if (actorUuid != null) { + UUID instanceId = (UUID)event.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID); + Entity actor = resolveActor(actorUuid); + String locator = ((ParticleKeyframeData)event.keyframeData()).getLocator(); + MinecraftClient client = MinecraftClient.getInstance(); + long worldTick = client != null && client.world != null ? client.world.getTime() : Long.MIN_VALUE; + Vec3d spawnPosition = ParticleKeyframes.findParticleLocatorPosition(instanceId, actorUuid, worldTick, locator); + if (spawnPosition != null) { + spawnPosition = spawnPosition.add(0.0, -0.0625, 0.0); + NonAnimationParticleEvents.ParticleOverride override = ((NonAnimationParticleEvents.ParticleResolver)NonAnimationParticleEvents.RESOLVE + .invoker()) + .resolve( + new NonAnimationParticleEvents.ParticleContext( + actor, + actorUuid, + (Identifier)event.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_ID), + (Identifier)event.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_RESOURCE_ID), + (String)event.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_KEY), + particleId, + locator == null ? "" : locator, + ((ParticleKeyframeData)event.keyframeData()).script() == null ? "" : ((ParticleKeyframeData)event.keyframeData()).script(), + spawnPosition, + (UUID)event.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID) + ) + ); + if (override != null) { + if (override.cancel()) { + return; + } + + particle = override.particle(); + } + + if (particle != null) { + ClientWorld world = actor != null && actor.getEntityWorld() instanceof ClientWorld clientWorld ? clientWorld : null; + if (world == null) { + world = client == null ? null : client.world; + } + + Vec3d velocity = computeEjectVelocity(actor, instanceId, actorUuid, worldTick, locator); + spawnParticle(world, particle, spawnPosition, velocity); + } + } + } + } + } + } + } + + private static void spawnParticle(ClientWorld world, ParticleEffect particle, Vec3d pos, Vec3d velocity) { + if (world != null && particle != null && pos != null && pos.isFinite()) { + if (velocity != null && velocity.isFinite()) { + world.addParticleClient(particle, pos.x, pos.y, pos.z, velocity.x, velocity.y, velocity.z); + } else { + world.addParticleClient(particle, pos.x, pos.y, pos.z, 0.0, 0.0, 0.0); + } + } + } + + private static Vec3d computeEjectVelocity(@Nullable Entity actor, @Nullable UUID instanceId, UUID actorUuid, long worldTick, @Nullable String locator) { + if (actor == null || instanceId == null || actorUuid == null) { + return Vec3d.ZERO; + } + + Vec3d direction = ParticleKeyframes.findLocatorDirection(instanceId, actorUuid, worldTick, locator); + if (direction == null || direction.lengthSquared() <= 1.0E-4) { + return Vec3d.ZERO; + } + + double energyRatio = 0.0; + if (actor instanceof EnergyHolder holder) { + int max = holder.getMaxEnergy(); + if (max > 0) { + energyRatio = Math.max(0.0, Math.min(1.0, (double)holder.getEnergy() / (double)max)); + } + } + + double minSpeed = 0.06; + double maxSpeed = 0.3; + double speed = minSpeed + (maxSpeed - minSpeed) * energyRatio; + return direction.multiply(speed); + } + + private static Entity resolveActor(UUID actorUuid) { + if (actorUuid == null) { + return null; + } + + MinecraftClient client = MinecraftClient.getInstance(); + return client != null && client.world != null ? client.world.getEntity(actorUuid) : null; + } + + private static void warnMissingAnimation(Identifier animationResource) { + if (MISSING_ANIMATION_WARNED.add(animationResource)) { + AnimationEngineClient.sendClientDebugChat( + NonDebugChatCategory.SETUP, Text.literal("[NoN Animation] Missing animation file on client: " + animationResource + " (skipping)") + ); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorGeoModel.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorGeoModel.java new file mode 100644 index 0000000..6346507 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/AnimationActorGeoModel.java @@ -0,0 +1,38 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.internal.animation.AnimationEngine; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.model.GeoModel; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public final class AnimationActorGeoModel extends GeoModel { + private static final ThreadLocal CURRENT_ANIMATION_RESOURCE = new ThreadLocal<>(); + private static final Identifier DEFAULT_MODEL = AnimationEngine.packId("entity/missingmodel"); + private static final Identifier DEFAULT_TEXTURE = AnimationEngine.packId("textures/missingmodel.png"); + private static final Identifier PLACEHOLDER_ANIMATIONS = AnimationEngine.packId("afw/placeholder"); + + public static void setCurrentAnimationResource(Identifier resource) { + CURRENT_ANIMATION_RESOURCE.set(resource); + } + + public static void clearCurrentAnimationResource() { + CURRENT_ANIMATION_RESOURCE.remove(); + } + + @NotNull + public Identifier getModelResource(GeoRenderState renderState) { + return (Identifier)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.MODEL_ID, DEFAULT_MODEL); + } + + @NotNull + public Identifier getTextureResource(GeoRenderState renderState) { + return (Identifier)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.TEXTURE_ID, DEFAULT_TEXTURE); + } + + @NotNull + public Identifier getAnimationResource(AnimationActorAnimatable animatable) { + Identifier resource = CURRENT_ANIMATION_RESOURCE.get(); + return resource != null ? resource : PLACEHOLDER_ANIMATIONS; + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/BoneTextureOverrides.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/BoneTextureOverrides.java new file mode 100644 index 0000000..1ce66cb --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/BoneTextureOverrides.java @@ -0,0 +1,364 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.AnimationEngineClient; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; + +public final class BoneTextureOverrides { + private static volatile Map> BONE_TEXTURES_BY_MODEL = Map.of(); + private static volatile Map> EMISSIVE_TEXTURES_BY_MODEL = Map.of(); + private static volatile Map> EMISSIVE_BONE_TEXTURES_BY_MODEL = Map.of(); + private static volatile Map> LOCATORS_BY_MODEL = Map.of(); + private static volatile Map RENDER_SETTINGS_BY_MODEL = Map.of(); + private static final Identifier RELOADER_ID = AnimationEngine.runtimeId("bone_textures"); + + private BoneTextureOverrides() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES).registerReloader(RELOADER_ID, new BoneTextureOverrides.Reloader()); + } + + public static Map getBoneTextures(Identifier modelId) { + return modelId == null ? null : BONE_TEXTURES_BY_MODEL.get(modelId); + } + + public static List getEmissiveTextures(Identifier modelId) { + return modelId == null ? null : EMISSIVE_TEXTURES_BY_MODEL.get(modelId); + } + + public static Map getEmissiveBoneTextures(Identifier modelId) { + return modelId == null ? null : EMISSIVE_BONE_TEXTURES_BY_MODEL.get(modelId); + } + + public static Map getLocators(Identifier modelId) { + return modelId == null ? null : LOCATORS_BY_MODEL.get(modelId); + } + + public static boolean isTranslucent(Identifier modelId) { + if (modelId == null) { + return false; + } + + BoneTextureOverrides.RenderSettings settings = RENDER_SETTINGS_BY_MODEL.get(modelId); + return settings != null && settings.translucent(); + } + + public static Set getTranslucentBones(Identifier modelId) { + if (modelId == null) { + return Set.of(); + } + + BoneTextureOverrides.RenderSettings settings = RENDER_SETTINGS_BY_MODEL.get(modelId); + return settings == null ? Set.of() : settings.translucentBones(); + } + + private static void reload(ResourceManager manager) { + Map resources = manager.findResources("geckolib/models", id -> id.getPath().endsWith(".geo.json")); + List> entries = new ArrayList<>(resources.entrySet()); + entries.sort(Comparator.comparing(ex -> ((Identifier)ex.getKey()).toString())); + Map> loaded = new LinkedHashMap<>(); + Map> loadedEmissive = new LinkedHashMap<>(); + Map> loadedEmissiveBones = new LinkedHashMap<>(); + Map> loadedLocators = new LinkedHashMap<>(); + Map loadedRenderSettings = new LinkedHashMap<>(); + + for (Entry entry : entries) { + Identifier fileId = entry.getKey(); + Identifier modelId = modelIdFromGeoPath(fileId); + if (modelId != null) { + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject(); + Map boneTextures = readBoneTextures(obj, fileId); + if (boneTextures != null && !boneTextures.isEmpty()) { + loaded.put(modelId, Map.copyOf(boneTextures)); + } + + List emissiveTextures = readEmissiveTextures(obj, fileId); + if (emissiveTextures != null && !emissiveTextures.isEmpty()) { + loadedEmissive.put(modelId, List.copyOf(emissiveTextures)); + } + + Map emissiveBoneTextures = readTextureMap(obj, fileId, "afw_emissive_bone_textures"); + if (emissiveBoneTextures != null && !emissiveBoneTextures.isEmpty()) { + loadedEmissiveBones.put(modelId, Map.copyOf(emissiveBoneTextures)); + } + + Map locators = readLocators(obj); + if (locators != null && !locators.isEmpty()) { + loadedLocators.put(modelId, Map.copyOf(locators)); + } + + BoneTextureOverrides.RenderSettings renderSettings = readRenderSettings(obj, fileId); + if (renderSettings != null && !renderSettings.isDefault()) { + loadedRenderSettings.put(modelId, renderSettings); + } + } catch (IOException | RuntimeException e) { + setupError("[NoN Animation] Failed to load Gecko model render metadata {}", fileId, e); + } + } + } + + BONE_TEXTURES_BY_MODEL = Map.copyOf(loaded); + EMISSIVE_TEXTURES_BY_MODEL = Map.copyOf(loadedEmissive); + EMISSIVE_BONE_TEXTURES_BY_MODEL = Map.copyOf(loadedEmissiveBones); + LOCATORS_BY_MODEL = Map.copyOf(loadedLocators); + RENDER_SETTINGS_BY_MODEL = Map.copyOf(loadedRenderSettings); + } + + private static Map readBoneTextures(JsonObject obj, Identifier fileId) { + return readTextureMap(obj, fileId, "afw_bone_textures"); + } + + private static Map readTextureMap(JsonObject obj, Identifier fileId, String key) { + if (!obj.has(key)) { + return null; + } + + JsonElement el = obj.get(key); + if (el != null && el.isJsonObject()) { + JsonObject bonesObj = el.getAsJsonObject(); + Map out = new LinkedHashMap<>(); + + for (Entry boneEntry : bonesObj.entrySet()) { + String boneName = boneEntry.getKey(); + JsonElement textureEl = boneEntry.getValue(); + if (boneName != null && !boneName.isBlank()) { + if (textureEl != null && textureEl.isJsonPrimitive()) { + String textureRaw = textureEl.getAsString(); + Identifier textureId = Identifier.tryParse(textureRaw); + if (textureId == null) { + setupWarn("[NoN Animation] Skipping {}: invalid texture id '{}' for bone '{}' in '{}'", fileId, textureRaw, boneName, key); + } else { + out.put(boneName, textureId); + } + } else { + setupWarn("[NoN Animation] Skipping {}: invalid texture for bone '{}' in '{}'", fileId, boneName, key); + } + } + } + + return out; + } else { + setupWarn("[NoN Animation] Skipping {}: '{}' must be an object", fileId, key); + return null; + } + } + + private static List readEmissiveTextures(JsonObject obj, Identifier fileId) { + if (!obj.has("afw_emissive_textures")) { + return null; + } + + JsonElement el = obj.get("afw_emissive_textures"); + if (el != null && el.isJsonArray()) { + List out = new ArrayList<>(); + + for (JsonElement textureEl : el.getAsJsonArray()) { + if (textureEl != null && textureEl.isJsonPrimitive()) { + String textureRaw = textureEl.getAsString(); + Identifier textureId = Identifier.tryParse(textureRaw); + if (textureId == null) { + setupWarn("[NoN Animation] Skipping {}: invalid emissive texture id '{}'", fileId, textureRaw); + } else { + out.add(textureId); + } + } else { + setupWarn("[NoN Animation] Skipping {}: invalid emissive texture entry", fileId); + } + } + + return out; + } else { + setupWarn("[NoN Animation] Skipping {}: 'afw_emissive_textures' must be an array", fileId); + return null; + } + } + + private static BoneTextureOverrides.RenderSettings readRenderSettings(JsonObject obj, Identifier fileId) { + if (!obj.has("afw_render")) { + return null; + } else { + JsonElement el = obj.get("afw_render"); + if (el != null && el.isJsonObject()) { + JsonObject renderObj = el.getAsJsonObject(); + boolean translucent = readOptionalBoolean(renderObj, "translucent", fileId, "afw_render", false); + Set translucentBones = readStringSet(renderObj, "translucent_bones", fileId, "afw_render"); + return new BoneTextureOverrides.RenderSettings(translucent, translucentBones); + } else { + setupWarn("[NoN Animation] Skipping {}: 'afw_render' must be an object", fileId); + return null; + } + } + } + + private static Set readStringSet(JsonObject obj, String key, Identifier fileId, String context) { + if (obj != null && obj.has(key)) { + JsonElement el = obj.get(key); + if (el != null && el.isJsonArray()) { + Set out = new LinkedHashSet<>(); + + for (JsonElement entry : el.getAsJsonArray()) { + if (entry != null && entry.isJsonPrimitive() && entry.getAsJsonPrimitive().isString()) { + String boneName = entry.getAsString(); + if (boneName != null && !boneName.isBlank()) { + out.add(boneName); + } else { + setupWarn("[NoN Animation] Skipping blank bone name in '{}.{}' for {}", context, key, fileId); + } + } else { + setupWarn("[NoN Animation] Skipping invalid bone name in '{}.{}' for {}", context, key, fileId); + } + } + + return out.isEmpty() ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(out)); + } else { + setupWarn("[NoN Animation] Skipping {}: '{}.{}' must be an array", fileId, context, key); + return Set.of(); + } + } else { + return Set.of(); + } + } + + private static boolean readOptionalBoolean(JsonObject obj, String key, Identifier fileId, String context, boolean fallback) { + if (obj != null && obj.has(key)) { + JsonElement el = obj.get(key); + if (el != null && el.isJsonPrimitive() && el.getAsJsonPrimitive().isBoolean()) { + return el.getAsBoolean(); + } + + setupWarn("[NoN Animation] Skipping {}: '{}.{}' must be a boolean", fileId, context, key); + return fallback; + } else { + return fallback; + } + } + + private static Map readLocators(JsonObject obj) { + JsonElement geometryEl = obj.get("minecraft:geometry"); + if (geometryEl != null && geometryEl.isJsonArray()) { + Map out = new LinkedHashMap<>(); + + for (JsonElement geometryEntry : geometryEl.getAsJsonArray()) { + if (geometryEntry != null && geometryEntry.isJsonObject()) { + JsonElement bonesEl = geometryEntry.getAsJsonObject().get("bones"); + if (bonesEl != null && bonesEl.isJsonArray()) { + for (JsonElement boneEntry : bonesEl.getAsJsonArray()) { + if (boneEntry != null && boneEntry.isJsonObject()) { + JsonObject boneObj = boneEntry.getAsJsonObject(); + JsonElement nameEl = boneObj.get("name"); + if (nameEl != null && nameEl.isJsonPrimitive()) { + String boneName = nameEl.getAsString(); + if (boneName != null && !boneName.isBlank()) { + double[] pivot = readVec3(boneObj.get("pivot")); + if (pivot == null) { + pivot = new double[]{0.0, 0.0, 0.0}; + } + + JsonElement locatorsEl = boneObj.get("locators"); + if (locatorsEl != null && locatorsEl.isJsonObject()) { + for (Entry locatorEntry : locatorsEl.getAsJsonObject().entrySet()) { + String locatorName = locatorEntry.getKey(); + if (locatorName != null && !locatorName.isBlank()) { + double[] locator = readLocatorVec3(locatorEntry.getValue()); + if (locator != null) { + out.put( + locatorName, + new BoneTextureOverrides.ModelLocator(boneName, locator[0], locator[1], locator[2], pivot[0], pivot[1], pivot[2]) + ); + } + } + } + } + } + } + } + } + } + } + } + + return out; + } else { + return null; + } + } + + private static double[] readLocatorVec3(JsonElement el) { + if (el == null) { + return null; + } else if (el.isJsonArray()) { + return readVec3(el); + } else { + return el.isJsonObject() ? readVec3(el.getAsJsonObject().get("offset")) : null; + } + } + + private static double[] readVec3(JsonElement el) { + if (el != null && el.isJsonArray()) { + JsonArray array = el.getAsJsonArray(); + return array.size() < 3 ? null : new double[]{array.get(0).getAsDouble(), array.get(1).getAsDouble(), array.get(2).getAsDouble()}; + } else { + return null; + } + } + + private static Identifier modelIdFromGeoPath(Identifier fileId) { + String path = fileId.getPath(); + String prefix = "geckolib/models/"; + String suffix = ".geo.json"; + if (path.startsWith(prefix) && path.endsWith(suffix)) { + String modelPath = path.substring(prefix.length(), path.length() - suffix.length()); + return Identifier.of(fileId.getNamespace(), modelPath); + } else { + return null; + } + } + + private static void setupWarn(String template, Object... args) { + AnimationEngine.LOGGER.warn(template, args); + AnimationEngineClient.sendClientSetupWarning(AnimationEngine.formatLogTemplate(template, args)); + } + + private static void setupError(String template, Object... args) { + AnimationEngine.LOGGER.error(template, args); + AnimationEngineClient.sendClientSetupWarning(AnimationEngine.formatLogTemplate(template, args)); + } + + public record ModelLocator(String parentBone, double x, double y, double z, double parentPivotX, double parentPivotY, double parentPivotZ) { + } + + public static final class Reloader implements SynchronousResourceReloader { + public void reload(ResourceManager manager) { + BoneTextureOverrides.reload(manager); + } + } + + private record RenderSettings(boolean translucent, Set translucentBones) { + private boolean isDefault() { + return !this.translucent && this.translucentBones.isEmpty(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoRenderTickets.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoRenderTickets.java new file mode 100644 index 0000000..dbf72fa --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoRenderTickets.java @@ -0,0 +1,41 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.internal.animation.client.compat.wildfire.WildfireGenderState; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.constant.dataticket.DataTicket; + +public final class GeckoRenderTickets { + public static final DataTicket<@NotNull Identifier> ANIMATION_ID = DataTicket.create("afw_animation_id", Identifier.class); + public static final DataTicket<@NotNull Identifier> ANIMATION_RESOURCE_ID = DataTicket.create("afw_animation_resource_id", Identifier.class); + public static final DataTicket<@NotNull String> ANIMATION_KEY = DataTicket.create("afw_animation_key", String.class); + public static final DataTicket<@NotNull UUID> ANIMATION_INSTANCE_ID = DataTicket.create("afw_instance_id", UUID.class); + public static final DataTicket<@NotNull UUID> ACTOR_UUID = DataTicket.create("afw_actor_uuid", UUID.class); + public static final DataTicket<@NotNull Identifier> MODEL_ID = DataTicket.create("afw_model_id", Identifier.class); + public static final DataTicket<@NotNull Identifier> TEXTURE_ID = DataTicket.create("afw_texture_id", Identifier.class); + public static final DataTicket<@NotNull List> LAYER_TEXTURES = DataTicket.create("afw_layer_textures", (Class)List.class); + public static final DataTicket<@NotNull List> EMISSIVE_TEXTURES = DataTicket.create("afw_emissive_textures", (Class)List.class); + public static final DataTicket<@NotNull Map> BONE_TEXTURES = DataTicket.create("afw_bone_textures", (Class)Map.class); + public static final DataTicket<@NotNull Map> EMISSIVE_BONE_TEXTURES = DataTicket.create("afw_emissive_bone_textures", (Class)Map.class); + public static final DataTicket<@NotNull Map> BONE_ITEMS = DataTicket.create("afw_bone_items", (Class)Map.class); + public static final DataTicket<@NotNull Map> BONE_VISIBILITY = DataTicket.create("afw_bone_visibility", (Class)Map.class); + public static final DataTicket<@NotNull Map>> HIDDEN_BONE_CUBE_INDICES = DataTicket.create("afw_hidden_bone_cube_indices", (Class)Map.class); + public static final DataTicket<@NotNull Boolean> TRANSLUCENT_RENDER = DataTicket.create("afw_translucent_render", Boolean.class); + public static final DataTicket<@NotNull Set> TRANSLUCENT_BONES = DataTicket.create("afw_translucent_bones", (Class)Set.class); + public static final DataTicket<@NotNull Identifier> LAST_ANIMATION_RESOURCE_ID = DataTicket.create("afw_last_animation_resource_id", Identifier.class); + public static final DataTicket<@NotNull String> LAST_ANIMATION_KEY = DataTicket.create("afw_last_animation_key", String.class); + public static final DataTicket<@NotNull UUID> LAST_ANIMATION_INSTANCE_ID = DataTicket.create("afw_last_instance_id", UUID.class); + public static final DataTicket<@NotNull Double> ANIMATION_SPEED = DataTicket.create("afw_animation_speed", Double.class); + public static final DataTicket<@NotNull Double> ANIMATION_TIMELINE_SECONDS = DataTicket.create("afw_animation_timeline_seconds", Double.class); + public static final DataTicket<@NotNull WildfireGenderState> WILDFIRE_GENDER_STATE = DataTicket.create( + "afw_wildfire_gender_state", WildfireGenderState.class + ); + + private GeckoRenderTickets() { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoReplacedRender.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoReplacedRender.java new file mode 100644 index 0000000..fe42b3e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoReplacedRender.java @@ -0,0 +1,616 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.GenderHolder; +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.client.compat.wildfire.WildfireGenderCompat; +import com.nonid.internal.animation.client.compat.wildfire.WildfireGenderState; +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.render.gecko.layer.BoneItemGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.BoneTextureGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.CameraPoseGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.EmissiveTextureGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.LayerTextureGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.ParticleKeyframeGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.TranslucentBoneGeoLayer; +import com.nonid.internal.animation.client.render.gecko.layer.WildfireGenderGeoLayer; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import com.nonid.internal.animation.mixin.client.EntityRenderManagerAccessor; +import com.nonid.internal.animation.util.EntityVariants; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.option.Perspective; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.EntityRenderManager; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.EntityRendererFactory.Context; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.client.render.state.CameraRenderState; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerSkinType; +import net.minecraft.entity.player.SkinTextures; +import net.minecraft.util.Identifier; +import software.bernie.geckolib.renderer.GeoReplacedEntityRenderer; +import software.bernie.geckolib.renderer.base.GeoRenderState; + +public final class GeckoReplacedRender { + private static final ConcurrentHashMap RENDERERS = new ConcurrentHashMap<>(); + private static final Identifier UNKNOWN_RENDERER_KEY = AnimationEngine.runtimeId("unknown_renderer"); + private static final Identifier PLAYER_TYPE_ID = Identifier.of("minecraft", "player"); + private static final String PLAYER_HEAD_BONE = "head"; + private static final float ACTIVE_MODEL_SCALE = 0.96F; + private static final ConcurrentHashMap, Method> TEXTURE_METHOD_CACHE = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap, Field> TEXTURE_FIELD_CACHE = new ConcurrentHashMap<>(); + private static final Set LOGGED_TEXTURE_RESOLUTION_ERRORS = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private GeckoReplacedRender() { + } + + private static GeoReplacedEntityRenderer getOrCreateRenderer(Identifier entityTypeId, Identifier modelId) { + Identifier key = modelId != null ? modelId : (entityTypeId != null ? entityTypeId : UNKNOWN_RENDERER_KEY); + GeoReplacedEntityRenderer existing = RENDERERS.get(key); + if (existing != null) { + return existing; + } else { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return null; + } else { + EntityRenderManager renderManager = client.getEntityRenderDispatcher(); + if (renderManager instanceof EntityRenderManagerAccessor accessor) { + Context context = new Context( + renderManager, + client.getItemModelManager(), + client.getMapRenderer(), + client.getBlockRenderManager(), + client.getResourceManager(), + client.getLoadedEntityModels(), + accessor.afw$getEquipmentModelLoader(), + client.getAtlasManager(), + client.textRenderer, + client.getPlayerSkinCache() + ); + GeoReplacedEntityRenderer created = new ReplacedEntityRenderer<>(context, new AnimationActorGeoModel(), AnimationActorAnimatable.INSTANCE); + created.withRenderLayer(new LayerTextureGeoLayer(created)); + created.withRenderLayer(new BoneTextureGeoLayer(created)); + created.withRenderLayer(new TranslucentBoneGeoLayer(created)); + created.withRenderLayer(new EmissiveTextureGeoLayer(created)); + created.withRenderLayer(new BoneItemGeoLayer(created)); + created.withRenderLayer(new ParticleKeyframeGeoLayer(created)); + created.withRenderLayer(new WildfireGenderGeoLayer(created)); + created.withRenderLayer(new CameraPoseGeoLayer(created)); + created.fireCompileRenderLayersEvent(); + GeoReplacedEntityRenderer prior = RENDERERS.putIfAbsent(key, created); + return prior != null ? prior : created; + } else { + return null; + } + } + } + } + + public static void prepare(LivingEntity entity, LivingEntityRenderState state, float tickDelta) { + if (state instanceof GeoRenderState geoState) { + geoState.addGeckolibData(GeckoRenderTickets.ACTOR_UUID, entity.getUuid()); + Identifier entityTypeId = ((AnimationRenderStateAccess)state).afw$getEntityTypeId(); + Identifier animationId = ClientAnimationRuntime.findLatestActiveAnimationIdContaining(entity.getUuid()); + List modelRoots = ClientAnimationRuntime.findLatestModelRootsForActor(entity.getUuid()); + UUID animationInstanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(entity.getUuid()); + Identifier animationResource = null; + double animationSpeed = 1.0; + if (animationId != null) { + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_ID, animationId); + if (animationInstanceId != null) { + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID, animationInstanceId); + } + + String actorKey = ClientAnimationRuntime.findLatestActiveActorKey(entity.getUuid()); + animationSpeed = ClientAnimationRuntime.findLatestSpeedForActor(entity.getUuid()); + animationResource = GeckoResourceResolver.resolveAnimationResource(animationId, actorKey, entityTypeId); + if (animationResource != null) { + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_RESOURCE_ID, animationResource); + String animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource, animationId, actorKey, entityTypeId); + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_KEY, animationKey == null ? "" : animationKey); + } + + Double timelineSeconds = ClientAnimationRuntime.findWarpedAuthoredTimelineSeconds(entity.getUuid(), tickDelta); + if (timelineSeconds != null && Double.isFinite(timelineSeconds)) { + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_TIMELINE_SECONDS, timelineSeconds); + } + } + + geoState.addGeckolibData(GeckoRenderTickets.ANIMATION_SPEED, animationSpeed); + List preferredModelPaths = preferredModelPaths(entity, entityTypeId); + GenderedModelResolver.ModelGender modelGender = entity instanceof GenderHolder holder + ? GenderedModelResolver.fromMask(holder.getGenderMask()) + : GenderedModelResolver.ModelGender.MALE; + GeckoResourceResolver.ModelAndTexture modelAndTex = GeckoResourceResolver.resolveModelAndTexture( + entityTypeId, modelRoots, preferredModelPaths, modelGender + ); + Identifier modelId = modelAndTex.model(); + Identifier fallbackTexture = modelAndTex.texture(); + boolean missingModel = modelAndTex.missingModel(); + boolean explicitTextureOverride = false; + NonAnimationRenderEvents.ModelOverride override = ((NonAnimationRenderEvents.ModelResolver)NonAnimationRenderEvents.RESOLVE.invoker()) + .resolve(entity, entityTypeId, modelId, fallbackTexture); + if (override != null) { + Identifier overrideModel = override.model(); + Identifier overrideTexture = override.texture(); + if (overrideModel != null && GeckoResourceResolver.hasGeoModel(overrideModel)) { + modelId = overrideModel; + missingModel = false; + } + + if (overrideTexture != null) { + fallbackTexture = overrideTexture; + explicitTextureOverride = true; + } + } + + Identifier vanillaTexture = resolveVanillaTexture(entity, state); + Identifier currentTexture = !missingModel && vanillaTexture != null && !explicitTextureOverride ? vanillaTexture : fallbackTexture; + NonAnimationRenderEvents.RenderOverride renderOverride = ((NonAnimationRenderEvents.RenderResolver)NonAnimationRenderEvents.RESOLVE_RENDER.invoker()) + .resolve(entity, entityTypeId, modelId, currentTexture); + List layerTextures = List.of(); + Map boneTextures = Map.of(); + Map runtimeBoneItems = ClientAnimationRuntime.findLatestBoneItemsForActor(entity.getUuid()); + Map boneItems = runtimeBoneItems; + Map boneVisibility = Map.of(); + Map> hiddenBoneCubeIndices = Map.of(); + if (renderOverride != null) { + Identifier overrideModel = renderOverride.model(); + Identifier overrideTexture = renderOverride.texture(); + if (overrideModel != null && GeckoResourceResolver.hasGeoModel(overrideModel)) { + modelId = overrideModel; + missingModel = false; + } + + if (overrideTexture != null) { + fallbackTexture = overrideTexture; + explicitTextureOverride = true; + } + + layerTextures = renderOverride.layerTextures(); + boneTextures = renderOverride.boneTextures(); + boneItems = mergeBoneItems(runtimeBoneItems, renderOverride.boneItems()); + boneVisibility = renderOverride.boneVisibility(); + hiddenBoneCubeIndices = renderOverride.hiddenBoneCubeIndices(); + } + + if (modelId == null || !GeckoResourceResolver.hasGeoModel(modelId)) { + GeckoResourceResolver.ModelAndTexture missing = GeckoResourceResolver.missingModelFallback(entityTypeId); + modelId = missing.model(); + fallbackTexture = missing.texture(); + missingModel = true; + } + + Map resourceBoneTextures = BoneTextureOverrides.getBoneTextures(modelId); + boneTextures = mergeBoneTextures(resourceBoneTextures, boneTextures); + List emissiveTextures = sanitizeLayerTextures(BoneTextureOverrides.getEmissiveTextures(modelId)); + Map emissiveBoneTextures = sanitizeBoneTextures(BoneTextureOverrides.getEmissiveBoneTextures(modelId)); + layerTextures = sanitizeLayerTextures(layerTextures); + boneTextures = sanitizeBoneTextures(boneTextures); + boneItems = sanitizeBoneItems(boneItems); + boneVisibility = sanitizeBoneVisibility(boneVisibility); + hiddenBoneCubeIndices = sanitizeHiddenBoneCubeIndices(hiddenBoneCubeIndices); + if (shouldHideLocalPlayerHeadInFirstPerson(entity)) { + boneVisibility = forceHiddenBone(boneVisibility, "head"); + } + + GeoReplacedEntityRenderer renderer = getOrCreateRenderer(entityTypeId, modelId); + if (renderer != null) { + geoState.addGeckolibData(GeckoRenderTickets.MODEL_ID, modelId); + Identifier finalTexture = explicitTextureOverride ? fallbackTexture : (!missingModel && vanillaTexture != null ? vanillaTexture : fallbackTexture); + finalTexture = VanillaTextureResolver.resolveTexture(finalTexture); + geoState.addGeckolibData(GeckoRenderTickets.TEXTURE_ID, finalTexture); + if (PLAYER_TYPE_ID.equals(entityTypeId)) { + WildfireGenderState wildfireGenderState = WildfireGenderCompat.resolve(entity, state); + if (wildfireGenderState != null) { + geoState.addGeckolibData(GeckoRenderTickets.WILDFIRE_GENDER_STATE, wildfireGenderState); + } + } + + geoState.addGeckolibData(GeckoRenderTickets.LAYER_TEXTURES, layerTextures); + geoState.addGeckolibData(GeckoRenderTickets.EMISSIVE_TEXTURES, emissiveTextures); + geoState.addGeckolibData(GeckoRenderTickets.BONE_TEXTURES, boneTextures); + geoState.addGeckolibData(GeckoRenderTickets.EMISSIVE_BONE_TEXTURES, emissiveBoneTextures); + geoState.addGeckolibData(GeckoRenderTickets.BONE_ITEMS, boneItems); + geoState.addGeckolibData(GeckoRenderTickets.BONE_VISIBILITY, boneVisibility); + geoState.addGeckolibData(GeckoRenderTickets.HIDDEN_BONE_CUBE_INDICES, hiddenBoneCubeIndices); + geoState.addGeckolibData(GeckoRenderTickets.TRANSLUCENT_RENDER, BoneTextureOverrides.isTranslucent(modelId)); + geoState.addGeckolibData(GeckoRenderTickets.TRANSLUCENT_BONES, BoneTextureOverrides.getTranslucentBones(modelId)); + + try { + if (animationResource != null) { + AnimationActorGeoModel.setCurrentAnimationResource(animationResource); + } + + renderer.fillRenderState(AnimationActorAnimatable.INSTANCE, entity, geoState, tickDelta); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.error("[NoN Animation] GeckoLib prepare failed", e); + } finally { + AnimationActorGeoModel.clearCurrentAnimationResource(); + } + } + } + } + + private static List sanitizeLayerTextures(List textures) { + if (textures != null && !textures.isEmpty()) { + List resolved = new ArrayList<>(textures.size()); + + for (Identifier texture : textures) { + if (texture != null) { + resolved.add(VanillaTextureResolver.resolveTexture(texture)); + } + } + + return resolved.isEmpty() ? List.of() : List.copyOf(resolved); + } else { + return List.of(); + } + } + + private static Map sanitizeBoneTextures(Map textures) { + if (textures != null && !textures.isEmpty()) { + Map resolved = new LinkedHashMap<>(); + + for (Entry entry : textures.entrySet()) { + String bone = entry.getKey(); + Identifier texture = entry.getValue(); + if (bone != null && !bone.isBlank() && texture != null) { + resolved.put(bone, VanillaTextureResolver.resolveTexture(texture)); + } + } + + return resolved.isEmpty() ? Map.of() : Map.copyOf(resolved); + } else { + return Map.of(); + } + } + + private static Map sanitizeBoneItems(Map items) { + if (items != null && !items.isEmpty()) { + Map resolved = new LinkedHashMap<>(); + + for (Entry entry : items.entrySet()) { + String bone = entry.getKey(); + NonAnimationRenderEvents.BoneItemProp prop = entry.getValue(); + if (bone != null && !bone.isBlank() && prop != null && !prop.stack().isEmpty()) { + resolved.put(bone, new NonAnimationRenderEvents.BoneItemProp(prop.stack(), prop.displayContext())); + } + } + + return resolved.isEmpty() ? Map.of() : Map.copyOf(resolved); + } else { + return Map.of(); + } + } + + private static Map mergeBoneItems( + Map baseItems, Map overrideItems + ) { + boolean baseEmpty = baseItems == null || baseItems.isEmpty(); + boolean overrideEmpty = overrideItems == null || overrideItems.isEmpty(); + if (baseEmpty && overrideEmpty) { + return Map.of(); + } + + if (baseEmpty) { + return Map.copyOf(overrideItems); + } + + if (overrideEmpty) { + return Map.copyOf(baseItems); + } + + LinkedHashMap merged = new LinkedHashMap<>(baseItems); + merged.putAll(overrideItems); + return Map.copyOf(merged); + } + + private static Map mergeBoneTextures(Map baseTextures, Map overrideTextures) { + boolean baseEmpty = baseTextures == null || baseTextures.isEmpty(); + boolean overrideEmpty = overrideTextures == null || overrideTextures.isEmpty(); + if (baseEmpty && overrideEmpty) { + return Map.of(); + } + + if (baseEmpty) { + return Map.copyOf(overrideTextures); + } + + if (overrideEmpty) { + return Map.copyOf(baseTextures); + } + + LinkedHashMap merged = new LinkedHashMap<>(baseTextures); + merged.putAll(overrideTextures); + return Map.copyOf(merged); + } + + private static Map sanitizeBoneVisibility(Map visibility) { + if (visibility != null && !visibility.isEmpty()) { + Map resolved = new LinkedHashMap<>(); + + for (Entry entry : visibility.entrySet()) { + String bone = entry.getKey(); + Boolean visible = entry.getValue(); + if (bone != null && !bone.isBlank() && visible != null) { + resolved.put(bone, visible); + } + } + + return resolved.isEmpty() ? Map.of() : Map.copyOf(resolved); + } else { + return Map.of(); + } + } + + private static Map> sanitizeHiddenBoneCubeIndices(Map> hiddenIndices) { + if (hiddenIndices != null && !hiddenIndices.isEmpty()) { + Map> resolved = new LinkedHashMap<>(); + + for (Entry> entry : hiddenIndices.entrySet()) { + String bone = entry.getKey(); + Set indices = entry.getValue(); + if (bone != null && !bone.isBlank() && indices != null && !indices.isEmpty()) { + Set cleanIndices = new LinkedHashSet<>(); + + for (Integer index : indices) { + if (index != null && index >= 0) { + cleanIndices.add(index); + } + } + + if (!cleanIndices.isEmpty()) { + resolved.put(bone, Set.copyOf(cleanIndices)); + } + } + } + + return resolved.isEmpty() ? Map.of() : Map.copyOf(resolved); + } else { + return Map.of(); + } + } + + private static Map forceHiddenBone(Map visibility, String boneName) { + if (boneName == null || boneName.isBlank()) { + return visibility; + } else if (visibility != null && !visibility.isEmpty()) { + Map merged = new LinkedHashMap<>(visibility); + merged.put(boneName, Boolean.FALSE); + return Map.copyOf(merged); + } else { + return Map.of(boneName, Boolean.FALSE); + } + } + + private static List preferredModelPaths(LivingEntity entity, Identifier entityTypeId) { + if (entityTypeId == null) { + return List.of(); + } + + String basePath = entityTypeId.getPath(); + List paths = new ArrayList<>(); + if (PLAYER_TYPE_ID.equals(entityTypeId) && entity instanceof AbstractClientPlayerEntity player && isSlimPlayerModel(player)) { + paths.add("player_slim"); + } + + String variant = EntityVariants.resolveVariant(entity); + if (variant != null && !variant.isBlank()) { + paths.add(basePath + "_" + variant); + } + + paths.add(basePath); + return List.copyOf(new LinkedHashSet<>(paths)); + } + + private static boolean shouldHideLocalPlayerHeadInFirstPerson(LivingEntity entity) { + if (entity instanceof AbstractClientPlayerEntity playerEntity) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null && client.options != null) { + if (!playerEntity.getUuid().equals(client.player.getUuid())) { + return false; + } else { + return client.options.getPerspective() != Perspective.FIRST_PERSON + ? false + : ClientAnimationRuntime.isActorPendingOrActive(playerEntity.getUuid()); + } + } else { + return false; + } + } else { + return false; + } + } + + public static boolean render(LivingEntityRenderState state, MatrixStack matrices, OrderedRenderCommandQueue queue, CameraRenderState camera) { + if (state instanceof GeoRenderState geoState) { + Identifier entityTypeId = null; + if (state instanceof AnimationRenderStateAccess access) { + entityTypeId = access.afw$getEntityTypeId(); + } + + Identifier modelId = (Identifier)geoState.getGeckolibData(GeckoRenderTickets.MODEL_ID); + GeoReplacedEntityRenderer renderer = getOrCreateRenderer(entityTypeId, modelId); + if (renderer == null) { + return false; + } + + try { + matrices.push(); + + try { + matrices.scale(0.96F, 0.96F, 0.96F); + renderer.performRenderPass(geoState, matrices, queue, camera); + } finally { + matrices.pop(); + } + + return true; + } catch (RuntimeException e) { + AnimationEngine.LOGGER.error("[NoN Animation] GeckoLib render failed", e); + return false; + } + } else { + return false; + } + } + + private static Identifier resolveVanillaTexture(LivingEntity entity, LivingEntityRenderState renderState) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return null; + } + + if (entity instanceof AbstractClientPlayerEntity playerEntity) { + try { + SkinTextures skin = playerEntity.getSkin(); + if (skin != null && skin.body() != null) { + Identifier skinTexture = skin.body().texturePath(); + if (skinTexture != null) { + return skinTexture; + } + } + } catch (RuntimeException ex) { + String key = "player|" + playerEntity.getUuidAsString(); + if (LOGGED_TEXTURE_RESOLUTION_ERRORS.add(key)) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to resolve player skin texture for {}", key, ex); + } + } + } + + try { + EntityRenderManager renderManager = client.getEntityRenderDispatcher(); + EntityRenderer vanillaRenderer = null; + if (renderManager instanceof EntityRenderManagerAccessor accessor) { + vanillaRenderer = accessor.afw$getRenderer(renderState); + } + + if (vanillaRenderer == null) { + vanillaRenderer = renderManager.getRenderer(entity); + } + + if (vanillaRenderer != null) { + Method m = TEXTURE_METHOD_CACHE.computeIfAbsent(vanillaRenderer.getClass(), cls -> { + Method found = findTextureMethod(cls.getDeclaredMethods(), renderState, entity); + return found != null ? found : findTextureMethod(cls.getMethods(), renderState, entity); + }); + if (m != null) { + Object arg = null; + if (m.getParameterCount() == 1) { + Class param = m.getParameterTypes()[0]; + if (param.isAssignableFrom(renderState.getClass())) { + arg = renderState; + } else if (param.isAssignableFrom(entity.getClass())) { + arg = entity; + } + } + + if ((m.getParameterCount() == 0 ? m.invoke(vanillaRenderer) : m.invoke(vanillaRenderer, arg)) instanceof Identifier id) { + return id; + } + } + } + + Field f = TEXTURE_FIELD_CACHE.computeIfAbsent(renderState.getClass(), cls -> { + for (Class current = (Class)cls; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (Identifier.class.isAssignableFrom(field.getType()) && !Modifier.isStatic(field.getModifiers())) { + field.setAccessible(true); + return field; + } + } + } + + return null; + }); + if (f != null && f.get(renderState) instanceof Identifier id && isUsableTextureId(client, id)) { + return id; + } + } catch (ReflectiveOperationException | RuntimeException ex) { + String key = entity.getType().toString() + "|" + renderState.getClass().getName(); + if (LOGGED_TEXTURE_RESOLUTION_ERRORS.add(key)) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to reflect vanilla texture for {}", key, ex); + } + } + + return null; + } + + private static Method findTextureMethod(Method[] methods, LivingEntityRenderState renderState, LivingEntity entity) { + for (Method method : methods) { + if (!Modifier.isStatic(method.getModifiers()) && Identifier.class.isAssignableFrom(method.getReturnType())) { + int params = method.getParameterCount(); + switch (params) { + case 0: + method.setAccessible(true); + return method; + case 1: + Class param = method.getParameterTypes()[0]; + if (param.isAssignableFrom(renderState.getClass()) + || param.isAssignableFrom(entity.getClass()) + || LivingEntity.class.isAssignableFrom(param) + || Entity.class.isAssignableFrom(param)) { + method.setAccessible(true); + return method; + } + } + } + } + + return null; + } + + private static boolean isUsableTextureId(MinecraftClient client, Identifier id) { + if (id == null) { + return false; + } + + String path = id.getPath(); + if (path == null || !path.contains("textures/") && !path.endsWith(".png")) { + try { + return client.getResourceManager().getResource(id).isPresent(); + } catch (RuntimeException ex) { + return false; + } + } else { + return true; + } + } + + private static boolean isSlimPlayerModel(AbstractClientPlayerEntity playerEntity) { + if (playerEntity == null) { + return false; + } + + try { + SkinTextures skin = playerEntity.getSkin(); + return skin != null && skin.model() == PlayerSkinType.SLIM; + } catch (RuntimeException ex) { + String key = "skin_model|" + playerEntity.getUuidAsString(); + if (LOGGED_TEXTURE_RESOLUTION_ERRORS.add(key)) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to resolve skin model from client API for {}", key, ex); + } + + return false; + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoResourceResolver.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoResourceResolver.java new file mode 100644 index 0000000..842a464 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/GeckoResourceResolver.java @@ -0,0 +1,496 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.client.NonDebugSpinMode; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.AnimationEngineClient; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.client.MinecraftClient; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import software.bernie.geckolib.GeckoLibConstants; +import software.bernie.geckolib.cache.GeckoLibResources; +import software.bernie.geckolib.loading.object.BakedAnimations; + +public final class GeckoResourceResolver { + private static final String SAFETY_FALLBACK_ANIMATION_KEY = "animation"; + private static final Identifier DEFAULT_MISSING_MODEL = AnimationEngine.packId("entity/missingmodel"); + private static final Identifier DEFAULT_MISSING_TEXTURE = AnimationEngine.packId("textures/missingmodel.png"); + private static final Set LOGGED_MISSING_TYPES = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Set LOGGED_MISSING_ANIMATIONS = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Set LOGGED_MISSING_ANIMATION_KEYS = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Set LOGGED_RESOURCE_LOOKUP_ERRORS = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Map ANIMATION_RESOURCE_CACHE = new ConcurrentHashMap<>(); + private static final Map> ANIMATION_KEY_CACHE = new ConcurrentHashMap<>(); + private static final Map FIRST_ANIMATION_KEY_CACHE = new ConcurrentHashMap<>(); + private static final Map MODEL_AND_TEXTURE_CACHE = new ConcurrentHashMap<>(); + private static final Map GEO_MODEL_CACHE = new ConcurrentHashMap<>(); + private static final Map RESOURCE_EXISTS_CACHE = new ConcurrentHashMap<>(); + + private GeckoResourceResolver() { + } + + public static Identifier toAnimationResource(Identifier animationId) { + if (NonDebugSpinMode.isEnabled()) { + return NonDebugSpinMode.DEBUG_SPIN_ANIMATION_RESOURCE; + } else { + return animationId == null ? null : Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), null)); + } + } + + public static Identifier toAnimationResource(Identifier animationId, String actorKey) { + if (NonDebugSpinMode.isEnabled()) { + return NonDebugSpinMode.DEBUG_SPIN_ANIMATION_RESOURCE; + } else { + return animationId == null ? null : Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), actorKey)); + } + } + + public static Identifier toConjoinedAnimationResource(Identifier animationId) { + if (animationId == null) { + return null; + } + + String folder = animationFolderPath(animationId.getPath()); + return folder.equals(animationId.getPath()) ? null : Identifier.of(animationId.getNamespace(), "afw/" + folder); + } + + private static Identifier toNestedConjoinedAnimationResource(Identifier animationId) { + if (animationId == null) { + return null; + } + + String folder = animationFolderPath(animationId.getPath()); + return folder.equals(animationId.getPath()) ? null : Identifier.of(animationId.getNamespace(), "afw/" + folder + "/" + folder); + } + + public static Identifier resolveAnimationResource(Identifier animationId, String actorKey, Identifier entityTypeId) { + return resolveAnimationResource(animationId, actorKey, entityTypeId, true); + } + + public static Identifier resolveAnimationResource(Identifier animationId, String actorKey, Identifier entityTypeId, boolean warnMissing) { + if (NonDebugSpinMode.isEnabled()) { + return NonDebugSpinMode.DEBUG_SPIN_ANIMATION_RESOURCE; + } + + if (animationId == null) { + return null; + } + + GeckoResourceResolver.AnimationResourceKey key = new GeckoResourceResolver.AnimationResourceKey(animationId, actorKey, entityTypeId); + GeckoResourceResolver.AnimationResourceResolution resolution = ANIMATION_RESOURCE_CACHE.computeIfAbsent(key, GeckoResourceResolver::findAnimationResource); + if (warnMissing && resolution.warnableMissing()) { + String logKey = animationId + "|actor=" + actorKey + "|type=" + entityTypeId; + if (LOGGED_MISSING_ANIMATIONS.add(logKey)) { + String message = AnimationEngine.formatLogTemplate( + "[NoN Animation] Missing per-actor animation for {} (actorKey={}, typeId={}); no base animation found.", animationId, actorKey, entityTypeId + ); + AnimationEngine.LOGGER.warn("{}", message); + AnimationEngineClient.sendClientSetupWarning(message); + } + } + + return resolution.resource(); + } + + private static GeckoResourceResolver.AnimationResourceResolution findAnimationResource(GeckoResourceResolver.AnimationResourceKey key) { + Identifier animationId = key.animationId(); + String actorKey = key.actorKey(); + Identifier entityTypeId = key.entityTypeId(); + Identifier conjoined = toConjoinedAnimationResource(animationId); + if (hasBakedAnimation(conjoined)) { + return new GeckoResourceResolver.AnimationResourceResolution(conjoined, false); + } + + Identifier nestedConjoined = toNestedConjoinedAnimationResource(animationId); + if (hasBakedAnimation(nestedConjoined)) { + return new GeckoResourceResolver.AnimationResourceResolution(nestedConjoined, false); + } + + Identifier actorCandidate = null; + if (actorKey != null && !actorKey.isBlank()) { + actorCandidate = Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), actorKey)); + if (hasBakedAnimation(actorCandidate)) { + return new GeckoResourceResolver.AnimationResourceResolution(actorCandidate, false); + } + } + + Identifier typeCandidate = null; + if (entityTypeId != null) { + String typeKey = entityTypeId.getPath(); + typeCandidate = Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), typeKey)); + if (hasBakedAnimation(typeCandidate)) { + return new GeckoResourceResolver.AnimationResourceResolution(typeCandidate, false); + } + + String namespacedKey = entityTypeId.getNamespace() + "_" + entityTypeId.getPath(); + if (!namespacedKey.equals(typeKey)) { + Identifier namespacedCandidate = Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), namespacedKey)); + if (hasBakedAnimation(namespacedCandidate)) { + return new GeckoResourceResolver.AnimationResourceResolution(namespacedCandidate, false); + } + } + } + + Identifier base = Identifier.of(animationId.getNamespace(), nestedAnimationPath(animationId.getPath(), null)); + boolean baseBaked = hasBakedAnimation(base); + return new GeckoResourceResolver.AnimationResourceResolution(base, !baseBaked && (actorCandidate != null || typeCandidate != null)); + } + + public static boolean hasBakedAnimation(Identifier animationResource) { + return animationResource != null && GeckoLibResources.getBakedAnimations().cache().containsKey(animationResource); + } + + public static boolean hasBakedAnimationKey(Identifier animationResource, String animationKey) { + if (animationResource != null && animationKey != null && !animationKey.isBlank()) { + BakedAnimations baked = (BakedAnimations)GeckoLibResources.getBakedAnimations().cache().get(animationResource); + return baked != null && baked.animations().containsKey(animationKey); + } else { + return false; + } + } + + public static String resolveAnimationKey(Identifier animationResource) { + return animationResource == null ? "animation" : FIRST_ANIMATION_KEY_CACHE.computeIfAbsent(animationResource, resource -> { + BakedAnimations baked = (BakedAnimations)GeckoLibResources.getBakedAnimations().cache().get(resource); + return baked != null && !baked.animations().isEmpty() ? (String)baked.animations().keySet().iterator().next() : "animation"; + }); + } + + @Nullable + public static String resolveAnimationKey(Identifier animationResource, Identifier animationId, String actorKey, Identifier entityTypeId) { + return resolveAnimationKey(animationResource, animationId, actorKey, entityTypeId, true); + } + + @Nullable + public static String resolveAnimationKey(Identifier animationResource, Identifier animationId, String actorKey, Identifier entityTypeId, boolean warnMissing) { + if (animationResource != null && animationId != null) { + GeckoResourceResolver.AnimationKeyKey key = new GeckoResourceResolver.AnimationKeyKey(animationResource, animationId, actorKey, entityTypeId); + Optional resolved = ANIMATION_KEY_CACHE.computeIfAbsent(key, GeckoResourceResolver::findAnimationKey); + if (resolved.isPresent()) { + return resolved.get(); + } + + if (warnMissing) { + String logKey = animationResource + "|stage=" + animationId + "|actor=" + actorKey + "|type=" + entityTypeId; + if (LOGGED_MISSING_ANIMATION_KEYS.add(logKey)) { + String message = AnimationEngine.formatLogTemplate( + "[NoN Animation] Missing actor clip in conjoined animation {} for stage {} (actorKey={}, typeId={}).", + animationResource, + animationId, + actorKey, + entityTypeId + ); + AnimationEngine.LOGGER.warn("{}", message); + AnimationEngineClient.sendClientSetupWarning(message); + } + } + + return null; + } else { + return null; + } + } + + private static Optional findAnimationKey(GeckoResourceResolver.AnimationKeyKey key) { + Identifier animationResource = key.animationResource(); + Identifier animationId = key.animationId(); + Identifier conjoined = toConjoinedAnimationResource(animationId); + Identifier nestedConjoined = toNestedConjoinedAnimationResource(animationId); + boolean isConjoined = animationResource.equals(conjoined) || animationResource.equals(nestedConjoined); + if (!isConjoined) { + return hasBakedAnimation(animationResource) ? Optional.of(resolveAnimationKey(animationResource)) : Optional.empty(); + } + + for (String candidate : conjoinedAnimationKeyCandidates(animationId, key.actorKey(), key.entityTypeId())) { + if (hasBakedAnimationKey(animationResource, candidate)) { + return Optional.of(candidate); + } + } + + return Optional.empty(); + } + + private static Set conjoinedAnimationKeyCandidates(Identifier animationId, String actorKey, Identifier entityTypeId) { + Set candidates = new LinkedHashSet<>(); + String stageKey = conjoinedStageKey(animationId.getPath()); + if (stageKey == null) { + return candidates; + } + + if (actorKey != null && !actorKey.isBlank()) { + candidates.add(stageKey + "_" + actorKey); + } + + if (entityTypeId != null) { + candidates.add(stageKey + "_" + entityTypeId.getPath()); + candidates.add(stageKey + "_" + entityTypeId.getNamespace() + "_" + entityTypeId.getPath()); + } + + return candidates; + } + + @Nullable + private static String conjoinedStageKey(String animationPath) { + String folder = animationFolderPath(animationPath); + return folder != null && !folder.equals(animationPath) && folder.length() + 1 < animationPath.length() + ? animationPath.substring(folder.length() + 1) + : null; + } + + public static GeckoResourceResolver.ModelAndTexture resolveModelAndTexture(Identifier entityTypeId) { + return entityTypeId == null + ? missingWithLog(null) + : resolveModelAndTexture(entityTypeId, List.of(), List.of(entityTypeId.getPath()), GenderedModelResolver.ModelGender.MALE); + } + + public static GeckoResourceResolver.ModelAndTexture resolveModelAndTexture( + Identifier entityTypeId, List modelRoots, List preferredModelPaths, GenderedModelResolver.ModelGender gender + ) { + if (entityTypeId == null) { + return missingWithLog(null); + } + + List safeRoots = normalizeRoots(modelRoots); + List safePaths = normalizeModelPaths(preferredModelPaths, entityTypeId.getPath()); + GenderedModelResolver.ModelGender safeGender = gender == null ? GenderedModelResolver.ModelGender.MALE : gender; + return MODEL_AND_TEXTURE_CACHE.computeIfAbsent( + new GeckoResourceResolver.ModelKey(entityTypeId, safeRoots, safePaths, safeGender), GeckoResourceResolver::findModelAndTexture + ); + } + + private static GeckoResourceResolver.ModelAndTexture findModelAndTexture(GeckoResourceResolver.ModelKey key) { + Identifier entityTypeId = key.entityTypeId(); + GeckoResourceResolver.ResolvedModel resolved = findPreferredModel(key.modelRoots(), key.modelPaths(), key.gender()); + Identifier resolvedModel = resolved == null ? modelIdAtRoot(null, key.modelPaths().getFirst()) : resolved.model(); + String entityPath = entityTypeId.getPath(); + Identifier derivedTexture = Identifier.of(entityTypeId.getNamespace(), "textures/entity/" + entityPath + "/" + entityPath + ".png"); + Identifier resolvedTexture = resourceExists(derivedTexture) ? derivedTexture : DEFAULT_MISSING_TEXTURE; + return new GeckoResourceResolver.ModelAndTexture(resolvedModel, resolvedTexture, resolved == null, resolved == null ? null : resolved.modelRoot()); + } + + @Nullable + public static Identifier resolveGenderedModelAcrossRoots( + @Nullable Identifier currentModel, List modelRoots, GenderedModelResolver.ModelGender gender + ) { + String modelPath = logicalModelPath(currentModel, modelRoots); + if (modelPath == null) { + return null; + } + + GeckoResourceResolver.ResolvedModel resolved = findPreferredModel( + normalizeRoots(modelRoots), List.of(modelPath), gender == null ? GenderedModelResolver.ModelGender.MALE : gender + ); + return resolved == null ? null : resolved.model(); + } + + @Nullable + private static GeckoResourceResolver.ResolvedModel findPreferredModel( + List roots, List modelPaths, GenderedModelResolver.ModelGender gender + ) { + for (String candidatePath : ModelVariantCandidates.ordered(modelPaths, gender)) { + GeckoResourceResolver.ResolvedModel resolved = findAcrossProviders(roots, candidatePath); + if (resolved != null) { + return resolved; + } + } + + return null; + } + + @Nullable + private static GeckoResourceResolver.ResolvedModel findAcrossProviders(List roots, String modelPath) { + for (Identifier root : roots) { + Identifier candidate = modelIdAtRoot(root, modelPath); + if (hasGeoModel(candidate)) { + return new GeckoResourceResolver.ResolvedModel(candidate, root); + } + } + + Identifier shared = modelIdAtRoot(null, modelPath); + if (hasGeoModel(shared)) { + return new GeckoResourceResolver.ResolvedModel(shared, null); + } + + Identifier legacyShared = AnimationEngine.packId(modelPath); + return hasGeoModel(legacyShared) ? new GeckoResourceResolver.ResolvedModel(legacyShared, null) : null; + } + + private static List normalizeRoots(List roots) { + if (roots != null && !roots.isEmpty()) { + LinkedHashSet unique = new LinkedHashSet<>(); + + for (Identifier root : roots) { + if (root != null) { + unique.add(root); + } + } + + return unique.isEmpty() ? List.of() : List.copyOf(unique); + } else { + return List.of(); + } + } + + private static List normalizeModelPaths(List paths, String fallback) { + LinkedHashSet unique = new LinkedHashSet<>(); + if (paths != null) { + for (String path : paths) { + if (path != null) { + String clean = path.trim(); + if (!clean.isEmpty()) { + unique.add(clean); + } + } + } + } + + if (unique.isEmpty() && fallback != null && !fallback.isBlank()) { + unique.add(fallback); + } + + return List.copyOf(unique); + } + + @Nullable + private static String logicalModelPath(@Nullable Identifier model, List roots) { + if (model == null) { + return null; + } + + Identifier base = GenderedModelResolver.stripGenderSuffix(model); + + for (Identifier root : normalizeRoots(roots)) { + if (root.getNamespace().equals(base.getNamespace())) { + String prefix = root.getPath() + "/"; + if (base.getPath().startsWith(prefix)) { + return base.getPath().substring(prefix.length()); + } + } + } + + if ("animationframework".equals(base.getNamespace())) { + String path = base.getPath(); + return path.startsWith("entity/") ? path.substring("entity/".length()) : path; + } else { + return null; + } + } + + public static Identifier modelIdAtRoot(@Nullable Identifier modelRoot, String modelPath) { + String safePath = modelPath == null ? "" : modelPath; + return modelRoot == null ? AnimationEngine.packId("entity/" + safePath) : Identifier.of(modelRoot.getNamespace(), modelRoot.getPath() + "/" + safePath); + } + + public static GeckoResourceResolver.ModelAndTexture missingModelFallback(Identifier typeId) { + return missingWithLog(typeId); + } + + public static boolean hasGeoModel(Identifier modelId) { + return modelId == null ? false : GEO_MODEL_CACHE.computeIfAbsent(modelId, GeckoResourceResolver::findGeoModel); + } + + private static boolean findGeoModel(Identifier modelId) { + if (GeckoLibResources.getBakedModels().cache().containsKey(modelId)) { + return true; + } + + Identifier geoPath = geoModelPath(modelId); + return resourceExists(geoPath); + } + + private static String nestedAnimationPath(String animationPath, String suffix) { + String folder = animationFolderPath(animationPath); + String file = suffix != null && !suffix.isBlank() ? animationPath + "_" + suffix : animationPath; + return "afw/" + folder + "/" + file; + } + + private static String animationFolderPath(String animationPath) { + if (animationPath != null && !animationPath.isEmpty()) { + int stageIndex = animationPath.lastIndexOf(".p"); + if (stageIndex > 0 && stageIndex + 2 < animationPath.length()) { + for (int i = stageIndex + 2; i < animationPath.length(); i++) { + char c = animationPath.charAt(i); + if (c < '0' || c > '9') { + return animationPath; + } + } + + return animationPath.substring(0, stageIndex); + } else { + return animationPath; + } + } else { + return animationPath; + } + } + + private static Identifier geoModelPath(Identifier modelId) { + return Identifier.of(modelId.getNamespace(), "geckolib/models/" + modelId.getPath() + ".geo.json"); + } + + private static boolean resourceExists(Identifier resourceId) { + return resourceId == null ? false : RESOURCE_EXISTS_CACHE.computeIfAbsent(resourceId, GeckoResourceResolver::findResource); + } + + private static boolean findResource(Identifier resourceId) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return false; + } + + try { + return client.getResourceManager().getResource(resourceId).isPresent(); + } catch (RuntimeException ex) { + if (resourceId != null && LOGGED_RESOURCE_LOOKUP_ERRORS.add(resourceId.toString())) { + AnimationEngine.LOGGER.debug("[NoN Animation] Resource lookup failed for {}", resourceId, ex); + } + + return false; + } + } + + private static GeckoResourceResolver.ModelAndTexture missingWithLog(Identifier typeId) { + if (typeId != null && LOGGED_MISSING_TYPES.add(typeId.toString())) { + GeckoLibConstants.LOGGER.info("[NoN Animation] No specific gecko model for entity type {}, using missingmodel.", typeId); + } + + return new GeckoResourceResolver.ModelAndTexture(DEFAULT_MISSING_MODEL, DEFAULT_MISSING_TEXTURE, true, null); + } + + public static void clearResourceCaches() { + ANIMATION_RESOURCE_CACHE.clear(); + ANIMATION_KEY_CACHE.clear(); + FIRST_ANIMATION_KEY_CACHE.clear(); + MODEL_AND_TEXTURE_CACHE.clear(); + GEO_MODEL_CACHE.clear(); + RESOURCE_EXISTS_CACHE.clear(); + LOGGED_MISSING_TYPES.clear(); + LOGGED_MISSING_ANIMATIONS.clear(); + LOGGED_MISSING_ANIMATION_KEYS.clear(); + LOGGED_RESOURCE_LOOKUP_ERRORS.clear(); + } + + private record AnimationKeyKey(Identifier animationResource, Identifier animationId, String actorKey, Identifier entityTypeId) { + } + + private record AnimationResourceKey(Identifier animationId, String actorKey, Identifier entityTypeId) { + } + + private record AnimationResourceResolution(Identifier resource, boolean warnableMissing) { + } + + public record ModelAndTexture(Identifier model, Identifier texture, boolean missingModel, @Nullable Identifier modelRoot) { + } + + private record ModelKey(Identifier entityTypeId, List modelRoots, List modelPaths, GenderedModelResolver.ModelGender gender) { + } + + private record ResolvedModel(Identifier model, @Nullable Identifier modelRoot) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/GenderedModelResolver.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/GenderedModelResolver.java new file mode 100644 index 0000000..eb80b37 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/GenderedModelResolver.java @@ -0,0 +1,102 @@ +package com.nonid.internal.animation.client.render.gecko; + +import java.util.List; +import java.util.function.Predicate; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class GenderedModelResolver { + private static final String MALE_SUFFIX = ".m"; + private static final String FEMALE_SUFFIX = ".f"; + private static final String BOTH_SUFFIX = ".mf"; + private static final List ALL_SUFFIXES = List.of(".mf", ".m", ".f"); + + private GenderedModelResolver() { + } + + @Nullable + public static Identifier resolveExisting(@Nullable Identifier baseModel, GenderedModelResolver.ModelGender gender, Predicate exists) { + if (baseModel != null && gender != null && exists != null) { + if (hasGenderSuffix(baseModel)) { + return exists.test(baseModel) ? baseModel : null; + } + + List suffixes = preferredSuffixes(gender); + Identifier preferred = withSuffix(baseModel, suffixes.getFirst()); + if (exists.test(preferred)) { + return preferred; + } + + if (exists.test(baseModel)) { + return baseModel; + } + + for (int i = 1; i < suffixes.size(); i++) { + Identifier candidate = withSuffix(baseModel, suffixes.get(i)); + if (exists.test(candidate)) { + return candidate; + } + } + + return null; + } else { + return null; + } + } + + public static boolean hasGenderSuffix(@Nullable Identifier model) { + return model != null && hasGenderSuffix(model.getPath()); + } + + public static Identifier stripGenderSuffix(Identifier model) { + String path = model.getPath(); + + for (String suffix : ALL_SUFFIXES) { + if (path.endsWith(suffix)) { + return Identifier.of(model.getNamespace(), path.substring(0, path.length() - suffix.length())); + } + } + + return model; + } + + public static Identifier withGender(Identifier baseModel, GenderedModelResolver.ModelGender gender) { + return withSuffix(stripGenderSuffix(baseModel), preferredSuffixes(gender).getFirst()); + } + + public static GenderedModelResolver.ModelGender fromMask(int mask) { + return switch (mask & 3) { + case 2 -> GenderedModelResolver.ModelGender.FEMALE; + case 3 -> GenderedModelResolver.ModelGender.BOTH; + default -> GenderedModelResolver.ModelGender.MALE; + }; + } + + private static boolean hasGenderSuffix(String path) { + for (String suffix : ALL_SUFFIXES) { + if (path.endsWith(suffix)) { + return true; + } + } + + return false; + } + + private static List preferredSuffixes(GenderedModelResolver.ModelGender gender) { + return switch (gender) { + case MALE -> List.of(".m", ".f"); + case FEMALE -> List.of(".f", ".m"); + case BOTH -> List.of(".mf", ".m", ".f"); + }; + } + + private static Identifier withSuffix(Identifier model, String suffix) { + return Identifier.of(model.getNamespace(), model.getPath() + suffix); + } + + public enum ModelGender { + MALE, + FEMALE, + BOTH; + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/ModelVariantCandidates.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/ModelVariantCandidates.java new file mode 100644 index 0000000..67f6a29 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/ModelVariantCandidates.java @@ -0,0 +1,56 @@ +package com.nonid.internal.animation.client.render.gecko; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +final class ModelVariantCandidates { + private ModelVariantCandidates() { + } + + static List ordered(List modelPaths, GenderedModelResolver.ModelGender gender) { + LinkedHashSet uniquePaths = new LinkedHashSet<>(); + if (modelPaths != null) { + for (String path : modelPaths) { + if (path != null) { + String clean = path.trim(); + if (!clean.isEmpty()) { + uniquePaths.add(clean); + } + } + } + } + + if (uniquePaths.isEmpty()) { + return List.of(); + } + + GenderedModelResolver.ModelGender safeGender = gender == null ? GenderedModelResolver.ModelGender.MALE : gender; + + String preferredSuffix = switch (safeGender) { + case MALE -> ".m"; + case FEMALE -> ".f"; + case BOTH -> ".mf"; + }; + List paths = List.copyOf(uniquePaths); + List candidates = new ArrayList<>(); + + for (String path : paths) { + candidates.add(path + preferredSuffix); + } + + candidates.addAll(paths); + + for (String suffix : switch (safeGender) { + case MALE -> List.of(".f"); + case FEMALE -> List.of(".m"); + case BOTH -> List.of(".m", ".f"); + }) { + for (String path : paths) { + candidates.add(path + suffix); + } + } + + return List.copyOf(new LinkedHashSet<>(candidates)); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/ParticleKeyframes.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/ParticleKeyframes.java new file mode 100644 index 0000000..34a6c9a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/ParticleKeyframes.java @@ -0,0 +1,158 @@ +package com.nonid.internal.animation.client.render.gecko; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.util.math.Vec3d; + +public final class ParticleKeyframes { + private static final long MAX_SAMPLE_AGE_TICKS = 2L; + private static final String V_LOCATOR = "v_locator"; + private static final String A_LOCATOR = "a_locator"; + private static final ConcurrentHashMap LOCATOR_POSITIONS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap ACTOR_ORIGINS = new ConcurrentHashMap<>(); + + private ParticleKeyframes() { + } + + public static void updateLocatorPositions(UUID instanceId, UUID actorUuid, long worldTick, Map positions) { + updateLocatorData(instanceId, actorUuid, worldTick, positions, Map.of()); + } + + public static void updateLocatorData( + UUID instanceId, UUID actorUuid, long worldTick, Map positions, Map directions + ) { + if (instanceId != null && actorUuid != null) { + ParticleKeyframes.LocatorKey key = new ParticleKeyframes.LocatorKey(instanceId, actorUuid); + if (positions != null && !positions.isEmpty()) { + Map clean = new LinkedHashMap<>(); + Map cleanDirections = new LinkedHashMap<>(); + + for (Entry entry : positions.entrySet()) { + String locator = cleanLocator(entry.getKey()); + Vec3d position = entry.getValue(); + if (!locator.isEmpty() && position != null && position.isFinite()) { + clean.put(locator, position); + } + } + + if (directions != null) { + for (Entry entry : directions.entrySet()) { + String locator = cleanLocator(entry.getKey()); + Vec3d direction = entry.getValue(); + if (!locator.isEmpty() && direction != null && direction.isFinite()) { + cleanDirections.put(locator, direction); + } + } + } + + if (clean.isEmpty()) { + LOCATOR_POSITIONS.remove(key); + } else { + LOCATOR_POSITIONS.put(key, new ParticleKeyframes.LocatorSample(worldTick, Map.copyOf(clean), Map.copyOf(cleanDirections))); + } + } else { + LOCATOR_POSITIONS.remove(key); + } + } + } + + public static Vec3d findLocatorPosition(UUID instanceId, UUID actorUuid, long worldTick, String locator) { + if (instanceId != null && actorUuid != null) { + String cleanLocator = cleanLocator(locator); + if (cleanLocator.isEmpty()) { + return null; + } + + ParticleKeyframes.LocatorKey key = new ParticleKeyframes.LocatorKey(instanceId, actorUuid); + ParticleKeyframes.LocatorSample sample = LOCATOR_POSITIONS.get(key); + if (sample == null) { + return null; + } + + long age = worldTick - sample.worldTick(); + if (age >= 0L && age <= 2L) { + return sample.positions().get(cleanLocator); + } + + LOCATOR_POSITIONS.remove(key, sample); + return null; + } else { + return null; + } + } + + public static Vec3d findParticleLocatorPosition(UUID instanceId, UUID actorUuid, long worldTick, String locator) { + String cleanLocator = cleanLocator(locator); + Vec3d position = findLocatorPosition(instanceId, actorUuid, worldTick, cleanLocator); + return position == null && "v_locator".equals(cleanLocator) ? findLocatorPosition(instanceId, actorUuid, worldTick, "a_locator") : position; + } + + public static Vec3d findLocatorDirection(UUID instanceId, UUID actorUuid, long worldTick, String locator) { + if (instanceId != null && actorUuid != null) { + String cleanLocator = cleanLocator(locator); + if (cleanLocator.isEmpty()) { + return null; + } + + ParticleKeyframes.LocatorKey key = new ParticleKeyframes.LocatorKey(instanceId, actorUuid); + ParticleKeyframes.LocatorSample sample = LOCATOR_POSITIONS.get(key); + if (sample == null) { + return null; + } + + long age = worldTick - sample.worldTick(); + if (age >= 0L && age <= 2L) { + Vec3d direction = sample.directions().get(cleanLocator); + return direction == null && "v_locator".equals(cleanLocator) ? sample.directions().get("a_locator") : direction; + } + + LOCATOR_POSITIONS.remove(key, sample); + return null; + } else { + return null; + } + } + + public static void updateActorOrigin(UUID actorUuid, Vec3d position) { + if (actorUuid != null && position != null && position.isFinite()) { + ACTOR_ORIGINS.put(actorUuid, position); + } + } + + public static Vec3d findActorOrigin(UUID actorUuid) { + return actorUuid == null ? null : ACTOR_ORIGINS.get(actorUuid); + } + + public static void retainActorOrigins(Set activeActorUuids) { + if (activeActorUuids != null && !activeActorUuids.isEmpty()) { + ACTOR_ORIGINS.keySet().removeIf(actorUuid -> !activeActorUuids.contains(actorUuid)); + } else { + ACTOR_ORIGINS.clear(); + } + } + + public static void clearInstance(UUID instanceId) { + if (instanceId != null) { + LOCATOR_POSITIONS.keySet().removeIf(key -> instanceId.equals(key.instanceId())); + } + } + + public static void clearAll() { + LOCATOR_POSITIONS.clear(); + ACTOR_ORIGINS.clear(); + } + + private static String cleanLocator(String locator) { + return locator == null ? "" : locator.trim(); + } + + private record LocatorKey(UUID instanceId, UUID actorUuid) { + } + + private record LocatorSample(long worldTick, Map positions, Map directions) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/ReplacedEntityRenderer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/ReplacedEntityRenderer.java new file mode 100644 index 0000000..f41c38c --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/ReplacedEntityRenderer.java @@ -0,0 +1,76 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.RenderLayers; +import net.minecraft.client.render.entity.EntityRendererFactory.Context; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.entity.Entity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.model.GeoModel; +import software.bernie.geckolib.renderer.GeoReplacedEntityRenderer; +import software.bernie.geckolib.renderer.base.BoneSnapshots; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.RenderPassInfo; + +public class ReplacedEntityRenderer + extends GeoReplacedEntityRenderer { + private static final Identifier PLAYER_TYPE_ID = Identifier.ofVanilla("player"); + + public ReplacedEntityRenderer(Context context, GeoModel<@NotNull T> model, T animatable) { + super(context, model, animatable); + } + + public RenderLayer getRenderType(R renderState, Identifier texture) { + boolean translucent = (Boolean)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.TRANSLUCENT_RENDER, false); + if (translucent) { + return RenderLayers.entityTranslucent(texture); + } else { + return renderState instanceof AnimationRenderStateAccess access && PLAYER_TYPE_ID.equals(access.afw$getEntityTypeId()) + ? RenderLayers.entityTranslucent(texture) + : super.getRenderType(renderState, texture); + } + } + + public void adjustModelBonesForRender(@NotNull RenderPassInfo renderPassInfo, @NotNull BoneSnapshots snapshots) { + super.adjustModelBonesForRender(renderPassInfo, snapshots); + R renderState = (R)renderPassInfo.renderState(); + Map boneTextures = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_TEXTURES, Map.of()); + Map boneVisibility = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_VISIBILITY, Map.of()); + Set translucentBones = (Set)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.TRANSLUCENT_BONES, Set.of()); + if (!boneTextures.isEmpty() || !boneVisibility.isEmpty() || !translucentBones.isEmpty()) { + for (String boneName : boneTextures.keySet()) { + if (boneName != null && !boneName.isBlank()) { + snapshots.ifPresent(boneName, snapshot -> { + snapshot.skipRender(true); + snapshot.skipChildrenRender(false); + }); + } + } + + for (Entry entry : boneVisibility.entrySet()) { + String boneName = entry.getKey(); + if (boneName != null && !boneName.isBlank() && Boolean.FALSE.equals(entry.getValue())) { + snapshots.ifPresent(boneName, snapshot -> { + snapshot.skipRender(true); + snapshot.skipChildrenRender(false); + }); + } + } + + for (String boneName : translucentBones) { + if (boneName != null && !boneName.isBlank()) { + snapshots.ifPresent(boneName, snapshot -> { + snapshot.skipRender(true); + snapshot.skipChildrenRender(false); + }); + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/VanillaTextureResolver.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/VanillaTextureResolver.java new file mode 100644 index 0000000..81dd623 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/VanillaTextureResolver.java @@ -0,0 +1,100 @@ +package com.nonid.internal.animation.client.render.gecko; + +import com.nonid.NeedsOfNature; +import com.nonid.internal.animation.AnimationEngine; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; + +public final class VanillaTextureResolver { + private static final Map REDIRECT_CACHE = new ConcurrentHashMap<>(); + private static final List UPLOADED_TEXTURES = new ArrayList<>(); + private static final Identifier RELOAD_LISTENER_ID = AnimationEngine.runtimeId("vanilla_textures"); + + private VanillaTextureResolver() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES).registerReloader(RELOAD_LISTENER_ID, (SynchronousResourceReloader)manager -> clearCache()); + } + + public static Identifier resolveTexture(Identifier textureId) { + if (textureId == null) { + return null; + } else { + return !NeedsOfNature.getConfig().forceVanillaEntityTextures() + ? textureId + : REDIRECT_CACHE.computeIfAbsent(textureId, VanillaTextureResolver::copyVanillaTexture); + } + } + + private static Identifier copyVanillaTexture(Identifier textureId) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) { + return textureId; + } + + ResourceManager resourceManager = client.getResourceManager(); + + try { + List resources = resourceManager.getAllResources(textureId); + if (resources.isEmpty()) { + return textureId; + } + + Resource vanillaResource = null; + + for (Resource res : resources) { + String packId = res.getPackId(); + if ("vanilla".equals(packId) || "minecraft".equals(packId) || "default".equalsIgnoreCase(packId)) { + vanillaResource = res; + break; + } + } + + if (vanillaResource == null) { + vanillaResource = resources.getLast(); + } + + try (InputStream stream = vanillaResource.getInputStream()) { + NativeImage image = NativeImage.read(stream); + Identifier redirected = AnimationEngine.runtimeId("vanilla_textures/" + textureId.getNamespace() + "/" + textureId.getPath()); + client.getTextureManager().registerTexture(redirected, new NativeImageBackedTexture(() -> "non_animation_vanilla_texture", image)); + synchronized (UPLOADED_TEXTURES) { + UPLOADED_TEXTURES.add(redirected); + } + + return redirected; + } + } catch (IOException | RuntimeException e) { + AnimationEngine.LOGGER.warn("[NoN Animation] Failed to pull vanilla texture {}, using active resource stack", textureId, e); + return textureId; + } + } + + public static void clearCache() { + REDIRECT_CACHE.clear(); + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + synchronized (UPLOADED_TEXTURES) { + for (Identifier id : UPLOADED_TEXTURES) { + client.getTextureManager().destroyTexture(id); + } + + UPLOADED_TEXTURES.clear(); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneItemGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneItemGeoLayer.java new file mode 100644 index 0000000..a30b7e9 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneItemGeoLayer.java @@ -0,0 +1,109 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.item.ItemDisplayContext; +import net.minecraft.item.ItemStack; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animation.state.BoneSnapshot; +import software.bernie.geckolib.cache.model.BakedGeoModel; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.renderer.base.BoneSnapshots; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.builtin.BlockAndItemGeoLayer; +import software.bernie.geckolib.renderer.layer.builtin.BlockAndItemGeoLayer.RenderData; +import software.bernie.geckolib.util.RenderUtil; + +public final class BoneItemGeoLayer extends BlockAndItemGeoLayer { + public BoneItemGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + @NotNull + protected List> getRelevantBones(R renderState, @NotNull BakedGeoModel model) { + return List.of(); + } + + public void addRenderData(T animatable, O relatedObject, R renderState, float partialTick) { + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + Map boneItems = this.getBoneItems((R)renderPassInfo.renderState()); + if (!boneItems.isEmpty()) { + Map boneSnapshots = new HashMap<>(); + renderPassInfo.addBoneUpdater((passInfo, snapshots) -> this.captureBoneSnapshots(passInfo.model().topLevelBones(), snapshots, boneSnapshots)); + renderPassInfo.renderPosed(() -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(renderPassInfo.poseStack().peek()); + + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + this.renderBoneItems(renderPassInfo, modelStack, bone, boneItems, boneSnapshots, renderTasks); + } + }); + } + } + } + + private Map getBoneItems(R renderState) { + return (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_ITEMS, Map.of()); + } + + private void renderBoneItems( + RenderPassInfo<@NotNull R> renderPassInfo, + MatrixStack poseStack, + GeoBone bone, + Map boneItems, + Map boneSnapshots, + OrderedRenderCommandQueue renderTasks + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + NonAnimationRenderEvents.BoneItemProp prop = boneItems.get(bone.name()); + BoneSnapshot snapshot = boneSnapshots.get(bone.name()); + boolean hidden = snapshot != null && snapshot.isHidden(); + if (!hidden && prop != null && !prop.stack().isEmpty()) { + ItemStack stack = prop.stack().copy(); + ItemDisplayContext context = prop.displayContext(); + poseStack.push(); + bone.translateToPivotPoint(poseStack); + super.submitItemStackRender( + poseStack, + bone, + stack, + context, + renderPassInfo.renderState(), + renderTasks, + renderPassInfo.cameraState(), + renderPassInfo.packedLight(), + renderPassInfo.packedOverlay(), + renderPassInfo.renderColor() + ); + poseStack.pop(); + } + + boolean hideChildren = snapshot != null && snapshot.areChildrenHidden(); + if (!hideChildren) { + for (GeoBone child : bone.children()) { + this.renderBoneItems(renderPassInfo, poseStack, child, boneItems, boneSnapshots, renderTasks); + } + } + + poseStack.pop(); + } + + private void captureBoneSnapshots(GeoBone[] bones, BoneSnapshots snapshots, Map out) { + for (GeoBone bone : bones) { + snapshots.get(bone.name()).ifPresent(snapshot -> out.put(bone.name(), snapshot)); + this.captureBoneSnapshots(bone.children(), snapshots, out); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneTextureGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneTextureGeoLayer.java new file mode 100644 index 0000000..587675e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/BoneTextureGeoLayer.java @@ -0,0 +1,140 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animation.state.BoneSnapshot; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.cache.model.cuboid.CuboidGeoBone; +import software.bernie.geckolib.cache.model.cuboid.GeoCube; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class BoneTextureGeoLayer extends GeoRenderLayer { + public BoneTextureGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + Map boneTextures = this.getBoneTextures((R)renderPassInfo.renderState()); + if (!boneTextures.isEmpty()) { + Set translucentBones = (Set)renderPassInfo.renderState().getOrDefaultGeckolibData(GeckoRenderTickets.TRANSLUCENT_BONES, Set.of()); + Map> bonesByTexture = new LinkedHashMap<>(); + + for (Entry entry : boneTextures.entrySet()) { + String boneName = entry.getKey(); + Identifier texture = entry.getValue(); + if (boneName != null && !boneName.isBlank() && texture != null && !translucentBones.contains(boneName)) { + bonesByTexture.computeIfAbsent(texture, key -> new LinkedHashSet<>()).add(boneName); + } + } + + if (!bonesByTexture.isEmpty()) { + int packedOverlay = renderPassInfo.packedOverlay(); + int packedLight = renderPassInfo.packedLight(); + int renderColor = renderPassInfo.renderColor(); + Set explicitlyHiddenBones = this.getExplicitlyHiddenBones((R)renderPassInfo.renderState()); + + for (Entry> entry : bonesByTexture.entrySet()) { + Identifier texture = entry.getKey(); + Set boneNames = entry.getValue(); + if (boneNames != null && !boneNames.isEmpty()) { + RenderLayer renderLayer = this.renderer.getRenderType(renderPassInfo.renderState(), texture); + if (renderLayer != null) { + renderTasks.submitCustom( + renderPassInfo.poseStack(), + renderLayer, + (entryMatrices, vertexConsumer) -> renderPassInfo.renderPosed( + () -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(entryMatrices); + + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + this.renderBoneFiltered( + modelStack, bone, vertexConsumer, boneNames, explicitlyHiddenBones, packedOverlay, packedLight, renderColor + ); + } + } + ) + ); + } + } + } + } + } + } + } + + private Map getBoneTextures(R renderState) { + return (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_TEXTURES, Map.of()); + } + + private void renderBoneFiltered( + MatrixStack poseStack, + GeoBone bone, + VertexConsumer buffer, + Set allowedBones, + Set explicitlyHiddenBones, + int packedOverlay, + int packedLight, + int renderColor + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + BoneSnapshot snapshot = bone.frameSnapshot; + boolean overrideBone = allowedBones.contains(bone.name()); + boolean explicitlyHidden = explicitlyHiddenBones.contains(bone.name()); + if (overrideBone && !explicitlyHidden && bone instanceof CuboidGeoBone cuboidBone) { + for (GeoCube cube : cuboidBone.cubes) { + poseStack.push(); + this.renderCube(cube, poseStack, buffer, packedOverlay, packedLight, renderColor); + poseStack.pop(); + } + } + + boolean hideChildren = snapshot != null && snapshot.areChildrenHidden(); + if (!hideChildren) { + for (GeoBone child : bone.children()) { + this.renderBoneFiltered(poseStack, child, buffer, allowedBones, explicitlyHiddenBones, packedOverlay, packedLight, renderColor); + } + } + + poseStack.pop(); + } + + private Set getExplicitlyHiddenBones(R renderState) { + Map boneVisibility = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_VISIBILITY, Map.of()); + if (boneVisibility.isEmpty()) { + return Set.of(); + } + + Set out = new LinkedHashSet<>(); + + for (Entry entry : boneVisibility.entrySet()) { + String boneName = entry.getKey(); + if (boneName != null && !boneName.isBlank() && Boolean.FALSE.equals(entry.getValue())) { + out.add(boneName); + } + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } + + private void renderCube(GeoCube cube, MatrixStack poseStack, VertexConsumer buffer, int packedOverlay, int packedLight, int renderColor) { + cube.render(poseStack, buffer, packedLight, packedOverlay, renderColor); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/CameraPoseGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/CameraPoseGeoLayer.java new file mode 100644 index 0000000..21de5b8 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/CameraPoseGeoLayer.java @@ -0,0 +1,208 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.camera.AnimatedCameraPoseTracker; +import com.nonid.internal.animation.client.camera.CameraPositionAccess; +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.UUID; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.NotNull; +import org.joml.Vector3f; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class CameraPoseGeoLayer extends GeoRenderLayer { + private static final Identifier PLAYER_TYPE_ID = Identifier.ofVanilla("player"); + private static final String HEAD_BONE = "head"; + private static final String BODY_BONE = "body"; + private static final float HEAD_CENTER_Y_OFFSET = 0.3F; + private static final float BODY_CENTER_Y_OFFSET = -0.375F; + + public CameraPoseGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + if (renderPassInfo.renderState() instanceof AnimationRenderStateAccess access) { + if (PLAYER_TYPE_ID.equals(access.afw$getEntityTypeId())) { + UUID actorUuid = access.afw$getEntityUuid(); + if (actorUuid != null) { + renderPassInfo.renderPosed( + () -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(renderPassInfo.poseStack().peek()); + CameraPoseGeoLayer.HeadSample headSample = this.findHeadSample(modelStack, renderPassInfo.model().topLevelBones()); + if (headSample != null) { + CameraPoseGeoLayer.BodySample bodySample = this.findBodySample(modelStack, renderPassInfo.model().topLevelBones()); + MinecraftClient client = MinecraftClient.getInstance(); + long nowTick = client != null && client.world != null ? client.world.getTime() : 0L; + UUID instanceId = (UUID)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID); + Identifier animationId = (Identifier)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_ID); + AnimatedCameraPoseTracker.capture( + actorUuid, + instanceId, + animationId, + headSample.worldPos(), + bodySample == null ? null : bodySample.worldPos(), + headSample.forward(), + headSample.up(), + headSample.right(), + bodySample == null ? null : bodySample.forward(), + bodySample == null ? null : bodySample.up(), + bodySample == null ? null : bodySample.right(), + nowTick + ); + } + } + ); + } + } + } + } + } + + private CameraPoseGeoLayer.HeadSample findHeadSample(MatrixStack poseStack, GeoBone[] bones) { + for (GeoBone bone : bones) { + CameraPoseGeoLayer.HeadSample headSample = this.findHeadSample(poseStack, bone); + if (headSample != null) { + return headSample; + } + } + + return null; + } + + private CameraPoseGeoLayer.HeadSample findHeadSample(MatrixStack poseStack, GeoBone bone) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + CameraPoseGeoLayer.HeadSample headSample = null; + if ("head".equalsIgnoreCase(bone.name())) { + poseStack.push(); + bone.translateToPivotPoint(poseStack); + Vec3d headPos = this.matrixToWorldPos(poseStack, 0.3F); + CameraPoseGeoLayer.Basis headBasis = this.matrixToBasis(poseStack); + if (headPos != null) { + headSample = new CameraPoseGeoLayer.HeadSample( + headPos, headBasis == null ? null : headBasis.forward(), headBasis == null ? null : headBasis.up(), headBasis == null ? null : headBasis.right() + ); + } + + poseStack.pop(); + } + + if (headSample == null) { + for (GeoBone child : bone.children()) { + headSample = this.findHeadSample(poseStack, child); + if (headSample != null) { + break; + } + } + } + + poseStack.pop(); + return headSample; + } + + private CameraPoseGeoLayer.BodySample findBodySample(MatrixStack poseStack, GeoBone[] bones) { + for (GeoBone bone : bones) { + CameraPoseGeoLayer.BodySample sample = this.findBodySample(poseStack, bone); + if (sample != null) { + return sample; + } + } + + return null; + } + + private CameraPoseGeoLayer.BodySample findBodySample(MatrixStack poseStack, GeoBone bone) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + CameraPoseGeoLayer.BodySample bodySample = null; + if ("body".equalsIgnoreCase(bone.name())) { + poseStack.push(); + bone.translateToPivotPoint(poseStack); + Vec3d bodyPos = this.matrixToWorldPos(poseStack, -0.375F); + CameraPoseGeoLayer.Basis bodyBasis = this.matrixToBasis(poseStack); + if (bodyPos != null) { + bodySample = new CameraPoseGeoLayer.BodySample( + bodyPos, bodyBasis == null ? null : bodyBasis.forward(), bodyBasis == null ? null : bodyBasis.up(), bodyBasis == null ? null : bodyBasis.right() + ); + } + + poseStack.pop(); + } + + if (bodySample == null) { + for (GeoBone child : bone.children()) { + bodySample = this.findBodySample(poseStack, child); + if (bodySample != null) { + break; + } + } + } + + poseStack.pop(); + return bodySample; + } + + private Vec3d matrixToWorldPos(MatrixStack poseStack, float localYOffset) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.gameRenderer != null && client.gameRenderer.getCamera() != null) { + Vector3f relative = poseStack.peek().getPositionMatrix().transformPosition(0.0F, localYOffset, 0.0F, new Vector3f()); + Vec3d cameraWorldPos = ((CameraPositionAccess)client.gameRenderer.getCamera()).afw$getPos(); + return new Vec3d(cameraWorldPos.x + relative.x(), cameraWorldPos.y + relative.y(), cameraWorldPos.z + relative.z()); + } else { + return null; + } + } + + private CameraPoseGeoLayer.Basis matrixToBasis(MatrixStack poseStack) { + Vector3f origin = poseStack.peek().getPositionMatrix().transformPosition(0.0F, 0.0F, 0.0F, new Vector3f()); + Vec3d forwardHint = this.vectorBetween(origin, poseStack.peek().getPositionMatrix().transformPosition(0.0F, 0.0F, -1.0F, new Vector3f())); + Vec3d upHint = this.vectorBetween(origin, poseStack.peek().getPositionMatrix().transformPosition(0.0F, 1.0F, 0.0F, new Vector3f())); + if (forwardHint != null && upHint != null) { + Vec3d forward = this.normalize(forwardHint); + Vec3d right = this.normalize(forward.crossProduct(upHint)); + if (forward != null && right != null) { + Vec3d up = this.normalize(right.crossProduct(forward)); + return up == null ? null : new CameraPoseGeoLayer.Basis(forward, up, right); + } else { + return null; + } + } else { + return null; + } + } + + private Vec3d vectorBetween(Vector3f origin, Vector3f target) { + return origin != null && target != null ? new Vec3d(target.x() - origin.x(), target.y() - origin.y(), target.z() - origin.z()) : null; + } + + private Vec3d normalize(Vec3d vector) { + if (vector != null && !(vector.lengthSquared() < 1.0E-8)) { + Vec3d normalized = vector.normalize(); + return normalized.isFinite() ? normalized : null; + } else { + return null; + } + } + + private record Basis(Vec3d forward, Vec3d up, Vec3d right) { + } + + private record BodySample(Vec3d worldPos, Vec3d forward, Vec3d up, Vec3d right) { + } + + private record HeadSample(Vec3d worldPos, Vec3d forward, Vec3d up, Vec3d right) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/EmissiveTextureGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/EmissiveTextureGeoLayer.java new file mode 100644 index 0000000..db7b7c5 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/EmissiveTextureGeoLayer.java @@ -0,0 +1,190 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.RenderLayers; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animation.state.BoneSnapshot; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.cache.model.cuboid.CuboidGeoBone; +import software.bernie.geckolib.cache.model.cuboid.GeoCube; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class EmissiveTextureGeoLayer extends GeoRenderLayer { + public EmissiveTextureGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + List emissiveTextures = (List)renderPassInfo.renderState() + .getOrDefaultGeckolibData(GeckoRenderTickets.EMISSIVE_TEXTURES, List.of()); + Map emissiveBoneTextures = (Map)renderPassInfo.renderState() + .getOrDefaultGeckolibData(GeckoRenderTickets.EMISSIVE_BONE_TEXTURES, Map.of()); + if (!emissiveTextures.isEmpty() || !emissiveBoneTextures.isEmpty()) { + int packedOverlay = renderPassInfo.packedOverlay(); + int renderColor = -1; + Set customTextureBones = this.getCustomTextureBones((R)renderPassInfo.renderState()); + Set explicitlyHiddenBones = this.getExplicitlyHiddenBones((R)renderPassInfo.renderState()); + + for (Identifier texture : emissiveTextures) { + if (texture != null) { + this.submitEmissivePass( + renderPassInfo, renderTasks, texture, Set.of(), customTextureBones, false, explicitlyHiddenBones, packedOverlay, renderColor + ); + } + } + + for (Entry> entry : this.groupBonesByTexture(emissiveBoneTextures).entrySet()) { + this.submitEmissivePass( + renderPassInfo, renderTasks, entry.getKey(), entry.getValue(), Set.of(), true, explicitlyHiddenBones, packedOverlay, renderColor + ); + } + } + } + } + + private void submitEmissivePass( + RenderPassInfo<@NotNull R> renderPassInfo, + OrderedRenderCommandQueue renderTasks, + Identifier texture, + Set includedBones, + Set excludedBones, + boolean restrictToIncludedBones, + Set explicitlyHiddenBones, + int packedOverlay, + int renderColor + ) { + if (texture != null) { + RenderLayer renderLayer = RenderLayers.entityCutoutNoCullZOffset(texture); + renderTasks.submitCustom( + renderPassInfo.poseStack(), + renderLayer, + (pose, vertexConsumer) -> renderPassInfo.renderPosed( + () -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(pose); + + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + this.renderBone( + modelStack, + bone, + vertexConsumer, + includedBones, + excludedBones, + restrictToIncludedBones, + explicitlyHiddenBones, + packedOverlay, + renderColor + ); + } + } + ) + ); + } + } + + private void renderBone( + MatrixStack poseStack, + GeoBone bone, + VertexConsumer buffer, + Set includedBones, + Set excludedBones, + boolean restrictToIncludedBones, + Set explicitlyHiddenBones, + int packedOverlay, + int renderColor + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + BoneSnapshot snapshot = bone.frameSnapshot; + boolean selected = restrictToIncludedBones ? includedBones.contains(bone.name()) : !excludedBones.contains(bone.name()); + boolean explicitlyHidden = explicitlyHiddenBones.contains(bone.name()); + boolean hiddenBySnapshot = snapshot != null && snapshot.isHidden(); + if (selected && !explicitlyHidden && (!hiddenBySnapshot || restrictToIncludedBones) && bone instanceof CuboidGeoBone cuboidBone) { + for (GeoCube cube : cuboidBone.cubes) { + poseStack.push(); + cube.render(poseStack, buffer, 15728880, packedOverlay, renderColor); + poseStack.pop(); + } + } + + boolean hideChildren = snapshot != null && snapshot.areChildrenHidden(); + if (!hideChildren) { + for (GeoBone child : bone.children()) { + this.renderBone(poseStack, child, buffer, includedBones, excludedBones, restrictToIncludedBones, explicitlyHiddenBones, packedOverlay, renderColor); + } + } + + poseStack.pop(); + } + + private Set getCustomTextureBones(R renderState) { + Map boneTextures = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_TEXTURES, Map.of()); + if (boneTextures.isEmpty()) { + return Set.of(); + } + + Set out = new LinkedHashSet<>(); + + for (Entry entry : boneTextures.entrySet()) { + String boneName = entry.getKey(); + if (boneName != null && !boneName.isBlank() && entry.getValue() != null) { + out.add(boneName); + } + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } + + private Map> groupBonesByTexture(Map boneTextures) { + if (boneTextures.isEmpty()) { + return Map.of(); + } + + Map> out = new LinkedHashMap<>(); + + for (Entry entry : boneTextures.entrySet()) { + String boneName = entry.getKey(); + Identifier texture = entry.getValue(); + if (boneName != null && !boneName.isBlank() && texture != null) { + out.computeIfAbsent(texture, ignored -> new LinkedHashSet<>()).add(boneName); + } + } + + return out; + } + + private Set getExplicitlyHiddenBones(R renderState) { + Map boneVisibility = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_VISIBILITY, Map.of()); + if (boneVisibility.isEmpty()) { + return Set.of(); + } + + Set out = new LinkedHashSet<>(); + + for (Entry entry : boneVisibility.entrySet()) { + String boneName = entry.getKey(); + if (boneName != null && !boneName.isBlank() && Boolean.FALSE.equals(entry.getValue())) { + out.add(boneName); + } + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/LayerTextureGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/LayerTextureGeoLayer.java new file mode 100644 index 0000000..5ec4882 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/LayerTextureGeoLayer.java @@ -0,0 +1,51 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.List; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.RenderLayers; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; + +public final class LayerTextureGeoLayer extends GeoRenderLayer { + public LayerTextureGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + List layerTextures = (List)renderPassInfo.renderState().getOrDefaultGeckolibData(GeckoRenderTickets.LAYER_TEXTURES, List.of()); + if (!layerTextures.isEmpty()) { + int packedLight = renderPassInfo.packedLight(); + int packedOverlay = renderPassInfo.packedOverlay(); + int renderColor = -1; + + for (Identifier texture : layerTextures) { + if (texture != null) { + RenderLayer renderLayer = RenderLayers.entityTranslucent(texture); + if (renderLayer != null) { + renderTasks.submitCustom( + renderPassInfo.poseStack(), + renderLayer, + (pose, vertexConsumer) -> { + renderPassInfo.poseStack().push(); + renderPassInfo.poseStack().peek().copy(pose); + renderPassInfo.renderPosed( + () -> renderPassInfo.model().render(renderPassInfo, vertexConsumer, packedLight, packedOverlay, renderColor) + ); + renderPassInfo.poseStack().pop(); + } + ); + } + } + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/ParticleKeyframeGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/ParticleKeyframeGeoLayer.java new file mode 100644 index 0000000..4e6fde1 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/ParticleKeyframeGeoLayer.java @@ -0,0 +1,182 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.camera.CameraPositionAccess; +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.render.gecko.BoneTextureOverrides; +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import com.nonid.internal.animation.client.render.gecko.ParticleKeyframes; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.NotNull; +import org.joml.Vector3f; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class ParticleKeyframeGeoLayer extends GeoRenderLayer { + public ParticleKeyframeGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + UUID actorUuid = (UUID)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.ACTOR_UUID); + UUID instanceId = (UUID)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.ANIMATION_INSTANCE_ID); + MinecraftClient client = MinecraftClient.getInstance(); + if (actorUuid != null && instanceId != null && client != null && client.world != null) { + long worldTick = client.world.getTime(); + Identifier modelId = (Identifier)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.MODEL_ID); + Map modelLocators = BoneTextureOverrides.getLocators(modelId); + if (modelLocators == null) { + modelLocators = Map.of(); + } + + String actorOriginBone = this.resolveActorOriginBone((R)renderPassInfo.renderState()); + if (modelLocators.isEmpty() && actorOriginBone == null) { + ParticleKeyframes.updateLocatorPositions(instanceId, actorUuid, worldTick, Map.of()); + } else { + ParticleKeyframeGeoLayer.PoseCapture capture = new ParticleKeyframeGeoLayer.PoseCapture(); + Map locators = modelLocators; + renderPassInfo.renderPosed( + () -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(renderPassInfo.poseStack().peek()); + if (locators.isEmpty()) { + capture.actorOrigin = renderPassInfo.model() + .getBone(actorOriginBone) + .map(bonex -> this.captureBonePosition(modelStack, bonex)) + .orElse(null); + } else { + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + this.capturePositions(modelStack, bone, locators, actorOriginBone, capture); + } + } + } + ); + ParticleKeyframes.updateLocatorData(instanceId, actorUuid, worldTick, capture.locators, capture.directions); + ParticleKeyframes.updateActorOrigin(actorUuid, capture.actorOrigin); + } + } + } + } + + private void capturePositions( + MatrixStack poseStack, + GeoBone bone, + Map modelLocators, + String actorOriginBone, + ParticleKeyframeGeoLayer.PoseCapture capture + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + if (capture.actorOrigin == null && bone.name().equals(actorOriginBone)) { + poseStack.push(); + bone.translateToPivotPoint(poseStack); + capture.actorOrigin = this.matrixToWorldPos(poseStack); + poseStack.pop(); + } + + for (Entry entry : modelLocators.entrySet()) { + BoneTextureOverrides.ModelLocator locator = entry.getValue(); + if (locator != null && bone.name().equals(locator.parentBone())) { + poseStack.push(); + bone.translateToPivotPoint(poseStack); + Vec3d pivotPos = this.matrixToWorldPos(poseStack); + poseStack.translate( + (float)((locator.x() - locator.parentPivotX()) / 16.0), + (float)((locator.y() - locator.parentPivotY()) / 16.0), + (float)((locator.z() - locator.parentPivotZ()) / 16.0) + ); + Vec3d worldPos = this.matrixToWorldPos(poseStack); + if (worldPos != null && worldPos.isFinite()) { + capture.locators.put(entry.getKey(), worldPos); + } + + if (worldPos != null && pivotPos != null && worldPos.isFinite() && pivotPos.isFinite()) { + Vec3d direction = worldPos.subtract(pivotPos); + if (direction.lengthSquared() > 1.0E-4) { + capture.directions.put(entry.getKey(), direction.normalize()); + } + } + + poseStack.pop(); + } + } + + for (GeoBone child : bone.children()) { + this.capturePositions(poseStack, child, modelLocators, actorOriginBone, capture); + } + + poseStack.pop(); + } + + private Vec3d captureBonePosition(MatrixStack poseStack, GeoBone target) { + Deque ancestry = new ArrayDeque<>(); + + for (GeoBone bone = target; bone != null; bone = bone.parent()) { + ancestry.addFirst(bone); + } + + int pushed = 0; + + try { + for (GeoBone bone : ancestry) { + poseStack.push(); + pushed++; + RenderUtil.prepMatrixForBone(poseStack, bone); + } + + poseStack.push(); + pushed++; + target.translateToPivotPoint(poseStack); + return this.matrixToWorldPos(poseStack); + } finally { + while (pushed-- > 0) { + poseStack.pop(); + } + } + } + + private String resolveActorOriginBone(R renderState) { + if (renderState instanceof AnimationRenderStateAccess access) { + Identifier entityTypeId = access.afw$getEntityTypeId(); + return entityTypeId != null && !entityTypeId.getPath().isBlank() ? entityTypeId.getPath() : null; + } else { + return null; + } + } + + private Vec3d matrixToWorldPos(MatrixStack poseStack) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.gameRenderer != null && client.gameRenderer.getCamera() != null) { + if (client.gameRenderer.getCamera() instanceof CameraPositionAccess access) { + Vector3f var6 = poseStack.peek().getPositionMatrix().transformPosition(0.0F, 0.0F, 0.0F, new Vector3f()); + Vec3d cameraWorldPos = access.afw$getPos(); + return new Vec3d(cameraWorldPos.x + var6.x(), cameraWorldPos.y + var6.y(), cameraWorldPos.z + var6.z()); + } else { + return null; + } + } else { + return null; + } + } + + private static final class PoseCapture { + private final Map locators = new LinkedHashMap<>(); + private final Map directions = new LinkedHashMap<>(); + private Vec3d actorOrigin; + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/TranslucentBoneGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/TranslucentBoneGeoLayer.java new file mode 100644 index 0000000..f9509ff --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/TranslucentBoneGeoLayer.java @@ -0,0 +1,149 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.RenderLayers; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.command.RenderCommandQueue; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animation.state.BoneSnapshot; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.cache.model.cuboid.CuboidGeoBone; +import software.bernie.geckolib.cache.model.cuboid.GeoCube; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class TranslucentBoneGeoLayer extends GeoRenderLayer { + private static final int OVERLAY_QUEUE = 1; + + public TranslucentBoneGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + R renderState = (R)renderPassInfo.renderState(); + Set translucentBones = (Set)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.TRANSLUCENT_BONES, Set.of()); + if (!translucentBones.isEmpty()) { + Identifier baseTexture = (Identifier)renderState.getGeckolibData(GeckoRenderTickets.TEXTURE_ID); + Map boneTextures = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_TEXTURES, Map.of()); + Map> bonesByTexture = this.groupBonesByTexture(translucentBones, boneTextures, baseTexture); + if (!bonesByTexture.isEmpty()) { + int packedOverlay = renderPassInfo.packedOverlay(); + int packedLight = renderPassInfo.packedLight(); + int renderColor = renderPassInfo.renderColor(); + Set explicitlyHiddenBones = this.getExplicitlyHiddenBones(renderState); + Map> hiddenCubeIndices = (Map>)renderState.getOrDefaultGeckolibData( + GeckoRenderTickets.HIDDEN_BONE_CUBE_INDICES, Map.of() + ); + RenderCommandQueue overlayTasks = renderTasks.getBatchingQueue(1); + + for (Entry> entry : bonesByTexture.entrySet()) { + RenderLayer renderLayer = RenderLayers.entityTranslucent(entry.getKey()); + Set boneNames = entry.getValue(); + overlayTasks.submitCustom( + renderPassInfo.poseStack(), + renderLayer, + (entryMatrices, vertexConsumer) -> renderPassInfo.renderPosed( + () -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(entryMatrices); + + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + this.renderBoneFiltered( + modelStack, bone, vertexConsumer, boneNames, explicitlyHiddenBones, hiddenCubeIndices, packedOverlay, packedLight, renderColor + ); + } + } + ) + ); + } + } + } + } + } + + private Map> groupBonesByTexture(Set translucentBones, Map boneTextures, Identifier baseTexture) { + Map> out = new LinkedHashMap<>(); + + for (String boneName : translucentBones) { + if (boneName != null && !boneName.isBlank()) { + Identifier texture = boneTextures.getOrDefault(boneName, baseTexture); + if (texture != null) { + out.computeIfAbsent(texture, ignored -> new LinkedHashSet<>()).add(boneName); + } + } + } + + return out; + } + + private void renderBoneFiltered( + MatrixStack poseStack, + GeoBone bone, + VertexConsumer buffer, + Set allowedBones, + Set explicitlyHiddenBones, + Map> hiddenCubeIndices, + int packedOverlay, + int packedLight, + int renderColor + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + BoneSnapshot snapshot = bone.frameSnapshot; + boolean selected = allowedBones.contains(bone.name()); + boolean explicitlyHidden = explicitlyHiddenBones.contains(bone.name()); + if (selected && !explicitlyHidden && bone instanceof CuboidGeoBone cuboidBone) { + Set hiddenIndices = hiddenCubeIndices.getOrDefault(bone.name(), Set.of()); + + for (int index = 0; index < cuboidBone.cubes.length; index++) { + if (!hiddenIndices.contains(index)) { + GeoCube cube = cuboidBone.cubes[index]; + poseStack.push(); + cube.render(poseStack, buffer, packedLight, packedOverlay, renderColor); + poseStack.pop(); + } + } + } + + boolean hideChildren = snapshot != null && snapshot.areChildrenHidden(); + if (!hideChildren) { + for (GeoBone child : bone.children()) { + this.renderBoneFiltered(poseStack, child, buffer, allowedBones, explicitlyHiddenBones, hiddenCubeIndices, packedOverlay, packedLight, renderColor); + } + } + + poseStack.pop(); + } + + private Set getExplicitlyHiddenBones(R renderState) { + Map boneVisibility = (Map)renderState.getOrDefaultGeckolibData(GeckoRenderTickets.BONE_VISIBILITY, Map.of()); + if (boneVisibility.isEmpty()) { + return Set.of(); + } + + Set out = new LinkedHashSet<>(); + + for (Entry entry : boneVisibility.entrySet()) { + String boneName = entry.getKey(); + if (boneName != null && !boneName.isBlank() && Boolean.FALSE.equals(entry.getValue())) { + out.add(boneName); + } + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/WildfireGenderGeoLayer.java b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/WildfireGenderGeoLayer.java new file mode 100644 index 0000000..b069571 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/render/gecko/layer/WildfireGenderGeoLayer.java @@ -0,0 +1,152 @@ +package com.nonid.internal.animation.client.render.gecko.layer; + +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.client.compat.wildfire.WildfireGenderCompat; +import com.nonid.internal.animation.client.compat.wildfire.WildfireGenderState; +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.UUID; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.client.util.math.MatrixStack; +import org.jetbrains.annotations.NotNull; +import org.joml.Quaternionf; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.GeoRenderer; +import software.bernie.geckolib.renderer.base.RenderPassInfo; +import software.bernie.geckolib.renderer.layer.GeoRenderLayer; +import software.bernie.geckolib.util.RenderUtil; + +public final class WildfireGenderGeoLayer extends GeoRenderLayer { + private static final String BODY_BONE = "body"; + private static boolean loggedMissingBodyBone; + + public WildfireGenderGeoLayer(GeoRenderer<@NotNull T, @NotNull O, @NotNull R> renderer) { + super(renderer); + } + + public void submitRenderTask(RenderPassInfo<@NotNull R> renderPassInfo, @NotNull OrderedRenderCommandQueue renderTasks) { + if (renderPassInfo.willRender()) { + WildfireGenderState state = (WildfireGenderState)renderPassInfo.renderState().getGeckolibData(GeckoRenderTickets.WILDFIRE_GENDER_STATE); + if (state != null) { + if (renderPassInfo.renderState() instanceof LivingEntityRenderState livingState) { + if (renderPassInfo.renderState() instanceof AnimationRenderStateAccess access) { + UUID actorUuid = access.afw$getEntityUuid(); + if (actorUuid != null) { + renderPassInfo.renderPosed(() -> { + MatrixStack modelStack = new MatrixStack(); + modelStack.peek().copy(renderPassInfo.poseStack().peek()); + MatrixStack rootStack = new MatrixStack(); + rootStack.peek().copy(renderPassInfo.poseStack().peek()); + MatrixStack bodyStack = this.findBonePose(rootStack, renderPassInfo, "body"); + if (bodyStack == null) { + logMissingBodyBone(); + } else { + this.submitAtBone(rootStack, bodyStack, modelStack, renderPassInfo, renderTasks, livingState, actorUuid, "body"); + } + }); + } + } + } + } + } + } + + private boolean submitAtBone( + MatrixStack rootPose, + MatrixStack physicsPose, + MatrixStack poseStack, + RenderPassInfo<@NotNull R> renderPassInfo, + OrderedRenderCommandQueue renderTasks, + LivingEntityRenderState livingState, + UUID actorUuid, + String targetBone + ) { + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + if (this.submitAtBoneRecursive(rootPose, physicsPose, poseStack, bone, renderTasks, livingState, actorUuid, renderPassInfo.packedLight(), targetBone)) { + return true; + } + } + + return false; + } + + private boolean submitAtBoneRecursive( + MatrixStack rootPose, + MatrixStack physicsPose, + MatrixStack poseStack, + GeoBone bone, + OrderedRenderCommandQueue renderTasks, + LivingEntityRenderState livingState, + UUID actorUuid, + int packedLight, + String targetBone + ) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + boolean submitted = false; + if (targetBone.equals(bone.name())) { + WildfireGenderCompat.overrideRenderPhysics(actorUuid, livingState, rootPose, physicsPose); + applyVanillaLayerCoordinateBridge(poseStack); + submitted = WildfireGenderCompat.submitOriginalLayer(livingState, poseStack, renderTasks, packedLight); + } else { + for (GeoBone child : bone.children()) { + if (this.submitAtBoneRecursive(rootPose, physicsPose, poseStack, child, renderTasks, livingState, actorUuid, packedLight, targetBone)) { + submitted = true; + break; + } + } + } + + poseStack.pop(); + return submitted; + } + + private MatrixStack findBonePose(MatrixStack rootPose, RenderPassInfo<@NotNull R> renderPassInfo, String targetBone) { + MatrixStack searchStack = new MatrixStack(); + searchStack.peek().copy(rootPose.peek()); + + for (GeoBone bone : renderPassInfo.model().topLevelBones()) { + MatrixStack found = this.findBonePoseRecursive(searchStack, bone, targetBone); + if (found != null) { + return found; + } + } + + return null; + } + + private MatrixStack findBonePoseRecursive(MatrixStack poseStack, GeoBone bone, String targetBone) { + poseStack.push(); + RenderUtil.prepMatrixForBone(poseStack, bone); + MatrixStack found = null; + if (targetBone.equals(bone.name())) { + found = new MatrixStack(); + found.peek().copy(poseStack.peek()); + } else { + for (GeoBone child : bone.children()) { + found = this.findBonePoseRecursive(poseStack, child, targetBone); + if (found != null) { + break; + } + } + } + + poseStack.pop(); + return found; + } + + private static void logMissingBodyBone() { + if (!loggedMissingBodyBone) { + loggedMissingBodyBone = true; + AnimationEngine.LOGGER.warn("[NoN Animation] Female Gender compatibility requires Gecko player bone '{}'; skipping layer.", "body"); + } + } + + private static void applyVanillaLayerCoordinateBridge(MatrixStack poseStack) { + poseStack.translate(0.0F, 1.5F, 0.0F); + poseStack.multiply(new Quaternionf().rotationZ((float) Math.PI)); + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/runtime/ClientAnimationRuntime.java b/src/main/java/com/nonid/internal/animation/client/runtime/ClientAnimationRuntime.java new file mode 100644 index 0000000..f586bb7 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/runtime/ClientAnimationRuntime.java @@ -0,0 +1,1742 @@ +package com.nonid.internal.animation.client.runtime; + +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.api.client.NonAnimationSoundEvents; +import com.nonid.internal.animation.client.diagnostics.AnimationAssetDiagnostics; +import com.nonid.internal.animation.client.render.gecko.GeckoResourceResolver; +import com.nonid.internal.animation.client.render.gecko.ParticleKeyframes; +import com.nonid.internal.animation.client.sound.AnimationSoundAnticipation; +import com.nonid.internal.animation.client.sound.AnimationSoundEffects; +import com.nonid.internal.animation.data.AnimationCueEffects; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.StageTimeWarp; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemDisplayContext; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; + +public final class ClientAnimationRuntime { + private static final List PENDING_STARTS = new ArrayList<>(); + private static final List PENDING_STAGE_ADVANCES = new ArrayList<>(); + private static final Map INSTANCES = new HashMap<>(); + private static final Map PENDING_STOP_TICKS = new HashMap<>(); + private static final Map PENDING_MANUAL_ADVANCE = new HashMap<>(); + private static final Map PENDING_TARGET_STAGE_ADVANCE = new HashMap<>(); + private static final Map PENDING_SPEED_UPDATES = new HashMap<>(); + private static final ArrayDeque COMPLETED_INSTANCES = new ArrayDeque<>(); + private static final Map KNOWN_LOOP_BY_ANIMATION_ID = new HashMap<>(); + private static final Map ACTIVE_ACTOR_LATEST_ANIMATION_ID = new HashMap<>(); + private static final Map> ACTIVE_ACTOR_MODEL_ROOTS = new HashMap<>(); + private static final Map ACTIVE_ACTOR_LATEST_KEY = new HashMap<>(); + private static final Map ACTIVE_ACTOR_SPEED = new HashMap<>(); + private static final Map> ACTIVE_ACTOR_BONE_ITEMS = new HashMap<>(); + private static final Map ACTOR_RENDER_HOLDS = new HashMap<>(); + private static final Map LOCKED_ORIENTATION_BY_ACTOR = new HashMap<>(); + private static volatile boolean HAS_ACTIVE_INSTANCES = false; + private static final ArrayDeque START_ORDER = new ArrayDeque<>(); + private static volatile long CURRENT_TICK = 0L; + private static final long RENDER_HANDOFF_HOLD_TICKS = 1L; + private static final String PROP_LEFT_BONE = "propleft"; + private static final String PROP_RIGHT_BONE = "propright"; + private static final String PROP_FLOOR_BONE = "propfloor"; + private static Long STOP_ALL_AT_TICK = null; + + private ClientAnimationRuntime() { + } + + public static void queueStart( + Identifier animationId, + List> modelRootsByActor, + UUID instanceId, + List actorUuids, + List actorKeys, + List stages, + long startTick, + int stageIndex, + long stageStartTick, + long stageEndTick, + double speed, + boolean lockOrientation, + float lockedYaw, + float lockedHeadYaw, + float lockedPitch, + Vec3d cameraOrbitTarget + ) { + List safeKeys = actorKeys == null ? List.of() : List.copyOf(actorKeys); + List safeStages = stages == null ? List.of() : List.copyOf(stages); + rememberStageMeta(animationId, safeStages); + AnimationAssetDiagnostics.validateStartPayload(animationId, safeKeys, safeStages); + UUID anchorUuid = actorUuids.isEmpty() ? null : actorUuids.getFirst(); + ClientAnimationRuntime.LockedOrientation locked = lockOrientation + ? new ClientAnimationRuntime.LockedOrientation(lockedYaw, lockedHeadYaw, lockedPitch) + : null; + List> safeModelRoots = modelRootsByActor == null + ? Collections.nCopies(actorUuids.size(), List.of()) + : modelRootsByActor.stream().map(roots -> roots == null ? List.of() : List.copyOf((Collection)roots)).toList(); + PENDING_STARTS.add( + new ClientAnimationRuntime.PendingStart( + instanceId, + animationId, + safeModelRoots, + List.copyOf(actorUuids), + safeKeys, + safeStages, + startTick, + stageIndex, + stageStartTick, + stageEndTick, + sanitizeSpeed(speed), + anchorUuid, + lockOrientation, + locked, + sanitizeCameraOrbitTarget(cameraOrbitTarget) + ) + ); + } + + public static void queueStop(UUID instanceId, long stopTick) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + state.stopAtTick = state.stopAtTick == null ? stopTick : Math.min(state.stopAtTick, stopTick); + } else { + PENDING_STOP_TICKS.merge(instanceId, stopTick, Math::min); + if (stopTick <= CURRENT_TICK) { + PENDING_STARTS.removeIf(ps -> ps.instanceId().equals(instanceId)); + } + } + } + + public static void queueStopAll(long stopTick) { + if (STOP_ALL_AT_TICK == null) { + STOP_ALL_AT_TICK = stopTick; + } else { + STOP_ALL_AT_TICK = Math.min(STOP_ALL_AT_TICK, stopTick); + } + } + + public static void stopAllNow() { + STOP_ALL_AT_TICK = null; + PENDING_STARTS.clear(); + PENDING_STAGE_ADVANCES.clear(); + PENDING_STOP_TICKS.clear(); + PENDING_MANUAL_ADVANCE.clear(); + PENDING_TARGET_STAGE_ADVANCE.clear(); + PENDING_SPEED_UPDATES.clear(); + INSTANCES.clear(); + START_ORDER.clear(); + ACTIVE_ACTOR_LATEST_ANIMATION_ID.clear(); + ACTIVE_ACTOR_MODEL_ROOTS.clear(); + ACTIVE_ACTOR_LATEST_KEY.clear(); + ACTIVE_ACTOR_SPEED.clear(); + ACTIVE_ACTOR_BONE_ITEMS.clear(); + ACTOR_RENDER_HOLDS.clear(); + LOCKED_ORIENTATION_BY_ACTOR.clear(); + HAS_ACTIVE_INSTANCES = false; + COMPLETED_INSTANCES.clear(); + KNOWN_LOOP_BY_ANIMATION_ID.clear(); + ParticleKeyframes.clearAll(); + } + + public static void requestStageAdvance(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + state.manualAdvanceRequests++; + } else { + PENDING_MANUAL_ADVANCE.merge(instanceId, 1, Integer::sum); + } + } + + public static boolean advanceStage(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state == null) { + return false; + } + + state.manualAdvanceRequests++; + return true; + } + + public static void queueStageAdvance(UUID instanceId, long advanceTick, long stageEndTick, int stageIndex) { + if (advanceTick <= CURRENT_TICK) { + requestStageAdvanceTo(instanceId, stageIndex, advanceTick, stageEndTick); + } else { + PENDING_STAGE_ADVANCES.add(new ClientAnimationRuntime.PendingStageAdvance(instanceId, advanceTick, stageEndTick, stageIndex)); + } + } + + private static void requestStageAdvanceTo(UUID instanceId, int stageIndex, long advanceTick, long stageEndTick) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + state.setStageIndex(stageIndex, Math.max(0L, advanceTick), stageEndTick); + ClientAnimationRuntime.PendingSpeedUpdate queuedSpeed = PENDING_SPEED_UPDATES.remove(instanceId); + if (queuedSpeed != null) { + applySpeedToState(state, queuedSpeed); + } + } else { + PENDING_TARGET_STAGE_ADVANCE.put( + instanceId, new ClientAnimationRuntime.PendingStageAdvance(instanceId, Math.max(0L, advanceTick), stageEndTick, stageIndex) + ); + } + } + + public static void queueSpeedUpdate(UUID instanceId, double speed, long stageStartTick, long stageEndTick) { + ClientAnimationRuntime.PendingSpeedUpdate update = new ClientAnimationRuntime.PendingSpeedUpdate(sanitizeSpeed(speed), stageStartTick, stageEndTick); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + if (hasPendingStageAdvance(instanceId)) { + PENDING_SPEED_UPDATES.put(instanceId, update); + } else { + applySpeedToState(state, update); + } + } else { + PENDING_SPEED_UPDATES.put(instanceId, update); + } + } + + private static boolean hasPendingStageAdvance(UUID instanceId) { + if (instanceId == null) { + return false; + } + + if (PENDING_TARGET_STAGE_ADVANCE.containsKey(instanceId)) { + return true; + } + + for (ClientAnimationRuntime.PendingStageAdvance pending : PENDING_STAGE_ADVANCES) { + if (pending != null && instanceId.equals(pending.instanceId())) { + return true; + } + } + + return false; + } + + public static List drainCompletedInstances() { + if (COMPLETED_INSTANCES.isEmpty()) { + return List.of(); + } + + List out = new ArrayList<>(COMPLETED_INSTANCES); + COMPLETED_INSTANCES.clear(); + return out; + } + + public static void clientTick(MinecraftClient client) { + if (client.world != null) { + long now = client.world.getTime(); + CURRENT_TICK = now; + if (STOP_ALL_AT_TICK != null && now >= STOP_ALL_AT_TICK) { + stopAllNow(); + } else if (PENDING_STARTS.isEmpty() + && INSTANCES.isEmpty() + && ACTOR_RENDER_HOLDS.isEmpty() + && PENDING_TARGET_STAGE_ADVANCE.isEmpty() + && STOP_ALL_AT_TICK == null) { + if (!PENDING_STOP_TICKS.isEmpty()) { + PENDING_STOP_TICKS.entrySet().removeIf(e -> e.getValue() <= now); + } + + if (!PENDING_STAGE_ADVANCES.isEmpty()) { + PENDING_STAGE_ADVANCES.removeIf(p -> p.advanceTick() <= now); + } + + if (!ACTIVE_ACTOR_LATEST_ANIMATION_ID.isEmpty() + || !ACTIVE_ACTOR_MODEL_ROOTS.isEmpty() + || !ACTIVE_ACTOR_LATEST_KEY.isEmpty() + || !ACTIVE_ACTOR_SPEED.isEmpty() + || !ACTIVE_ACTOR_BONE_ITEMS.isEmpty()) { + ACTIVE_ACTOR_LATEST_ANIMATION_ID.clear(); + ACTIVE_ACTOR_MODEL_ROOTS.clear(); + ACTIVE_ACTOR_LATEST_KEY.clear(); + ACTIVE_ACTOR_SPEED.clear(); + ACTIVE_ACTOR_BONE_ITEMS.clear(); + HAS_ACTIVE_INSTANCES = false; + } + + START_ORDER.clear(); + ParticleKeyframes.retainActorOrigins(Set.of()); + } else { + if (!ACTOR_RENDER_HOLDS.isEmpty()) { + ACTOR_RENDER_HOLDS.entrySet().removeIf(e -> now > e.getValue().expiresAtTick()); + } + + int i = 0; + + while (i < PENDING_STARTS.size()) { + ClientAnimationRuntime.PendingStart ps = PENDING_STARTS.get(i); + if (now >= ps.startTick()) { + ClientAnimationRuntime.LockedOrientation anchorOrientation = ps.lockOrientation() + ? ps.lockedOrientation() + : resolveOrientation(client, ps.anchorUuid()); + double initialSpeed = sanitizeSpeed(ps.speed()); + long initialStageStartTick = ps.stageStartTick(); + long initialStageEndTick = ps.stageEndTick(); + ClientAnimationRuntime.PendingSpeedUpdate queuedSpeed = PENDING_SPEED_UPDATES.remove(ps.instanceId()); + if (queuedSpeed != null) { + initialSpeed = queuedSpeed.speed(); + initialStageStartTick = queuedSpeed.stageStartTick(); + initialStageEndTick = queuedSpeed.stageEndTick(); + } + + ClientAnimationRuntime.InstanceState state = new ClientAnimationRuntime.InstanceState( + ps.instanceId(), + ps.animationId(), + ps.modelRootsByActor(), + ps.actorUuids(), + ps.actorKeys(), + ps.stages(), + ps.startTick(), + ps.stageIndex(), + initialStageStartTick, + initialStageEndTick, + initialSpeed, + ps.anchorUuid(), + anchorOrientation, + ps.cameraOrbitTarget() + ); + ClientAnimationRuntime.PendingStageAdvance pendingTargetStage = PENDING_TARGET_STAGE_ADVANCE.remove(ps.instanceId()); + if (pendingTargetStage != null) { + state.setStageIndex(pendingTargetStage.stageIndex(), pendingTargetStage.advanceTick(), pendingTargetStage.stageEndTick()); + } + + Integer pendingAdvance = PENDING_MANUAL_ADVANCE.remove(ps.instanceId()); + if (pendingTargetStage == null && pendingAdvance != null) { + state.manualAdvanceRequests = pendingAdvance; + } + + Long pendingStop = PENDING_STOP_TICKS.remove(ps.instanceId()); + if (pendingStop != null) { + state.stopAtTick = pendingStop; + } + + INSTANCES.put(ps.instanceId(), state); + START_ORDER.addLast(ps.instanceId()); + + for (UUID actorUuid : ps.actorUuids()) { + ACTOR_RENDER_HOLDS.remove(actorUuid); + } + + PENDING_STARTS.remove(i); + } else { + i++; + } + } + + if (!PENDING_STAGE_ADVANCES.isEmpty()) { + i = 0; + + while (i < PENDING_STAGE_ADVANCES.size()) { + ClientAnimationRuntime.PendingStageAdvance pending = PENDING_STAGE_ADVANCES.get(i); + if (now >= pending.advanceTick()) { + requestStageAdvanceTo(pending.instanceId(), pending.stageIndex(), pending.advanceTick(), pending.stageEndTick()); + PENDING_STAGE_ADVANCES.remove(i); + } else { + i++; + } + } + } + + if (!INSTANCES.isEmpty()) { + Iterator> expiredIt = INSTANCES.entrySet().iterator(); + + while (expiredIt.hasNext()) { + Entry entry = expiredIt.next(); + ClientAnimationRuntime.InstanceState state = entry.getValue(); + if (now >= state.effectiveStopTick()) { + rememberRenderHolds(state, now); + ParticleKeyframes.clearInstance(entry.getKey()); + expiredIt.remove(); + } + } + } + + if (!INSTANCES.isEmpty()) { + List completed = new ArrayList<>(); + + for (ClientAnimationRuntime.InstanceState state : INSTANCES.values()) { + if (now >= state.startTick) { + while (state.manualAdvanceRequests > 0) { + state.manualAdvanceRequests--; + if (!state.advanceStage(now)) { + completed.add(state.instanceId); + break; + } + + ClientAnimationRuntime.PendingSpeedUpdate queued = PENDING_SPEED_UPDATES.remove(state.instanceId); + if (queued != null) { + applySpeedToState(state, queued); + } + } + } + } + + if (!completed.isEmpty()) { + for (UUID id : completed) { + ClientAnimationRuntime.InstanceState completedState = INSTANCES.remove(id); + if (completedState != null) { + rememberRenderHolds(completedState, now); + } + + ParticleKeyframes.clearInstance(id); + PENDING_STOP_TICKS.remove(id); + PENDING_MANUAL_ADVANCE.remove(id); + PENDING_TARGET_STAGE_ADVANCE.remove(id); + PENDING_SPEED_UPDATES.remove(id); + START_ORDER.removeIf(existing -> existing.equals(id)); + COMPLETED_INSTANCES.add(id); + } + } + } + + if (START_ORDER.size() > 256) { + ArrayDeque compact = new ArrayDeque<>(START_ORDER.size()); + + for (UUID id : START_ORDER) { + if (INSTANCES.containsKey(id)) { + compact.addLast(id); + } + } + + START_ORDER.clear(); + START_ORDER.addAll(compact); + } else { + START_ORDER.removeIf(idx -> !INSTANCES.containsKey(idx)); + } + + ACTIVE_ACTOR_LATEST_ANIMATION_ID.clear(); + ACTIVE_ACTOR_MODEL_ROOTS.clear(); + ACTIVE_ACTOR_LATEST_KEY.clear(); + ACTIVE_ACTOR_SPEED.clear(); + ACTIVE_ACTOR_BONE_ITEMS.clear(); + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null) { + for (int ix = 0; ix < state.actorUuids.size(); ix++) { + UUID actorUuid = state.actorUuids.get(ix); + ACTIVE_ACTOR_LATEST_ANIMATION_ID.putIfAbsent(actorUuid, state.currentAnimationId()); + ACTIVE_ACTOR_MODEL_ROOTS.putIfAbsent(actorUuid, state.modelRootsForActor(ix)); + ACTIVE_ACTOR_SPEED.putIfAbsent(actorUuid, state.speed); + ACTIVE_ACTOR_BONE_ITEMS.putIfAbsent(actorUuid, resolveActorBoneItems(state, ix)); + if (ix < state.actorKeys.size()) { + String actorKey = state.actorKeys.get(ix); + if (actorKey != null && !actorKey.isEmpty()) { + ACTIVE_ACTOR_LATEST_KEY.putIfAbsent(actorUuid, actorKey); + } + } + } + } + } + + if (!ACTOR_RENDER_HOLDS.isEmpty()) { + for (Entry entry : ACTOR_RENDER_HOLDS.entrySet()) { + UUID actorUuid = entry.getKey(); + ClientAnimationRuntime.RenderHold hold = entry.getValue(); + ACTIVE_ACTOR_LATEST_ANIMATION_ID.putIfAbsent(actorUuid, hold.animationId()); + ACTIVE_ACTOR_MODEL_ROOTS.putIfAbsent(actorUuid, hold.modelRoots()); + ACTIVE_ACTOR_SPEED.putIfAbsent(actorUuid, hold.speed()); + ACTIVE_ACTOR_BONE_ITEMS.putIfAbsent(actorUuid, hold.boneItems()); + if (hold.actorKey() != null && !hold.actorKey().isEmpty()) { + ACTIVE_ACTOR_LATEST_KEY.putIfAbsent(actorUuid, hold.actorKey()); + } + } + } + + ParticleKeyframes.retainActorOrigins(ACTIVE_ACTOR_LATEST_ANIMATION_ID.keySet()); + HAS_ACTIVE_INSTANCES = !ACTIVE_ACTOR_LATEST_ANIMATION_ID.isEmpty(); + syncLockedOrientations(client); + tickSoundCues(client, now); + } + } + } + + private static void rememberRenderHolds(ClientAnimationRuntime.InstanceState state, long nowTick) { + if (state != null && !state.actorUuids.isEmpty()) { + long expiresAt = nowTick + 1L; + + for (int i = 0; i < state.actorUuids.size(); i++) { + UUID actorUuid = state.actorUuids.get(i); + if (actorUuid != null) { + String actorKey = i < state.actorKeys.size() ? state.actorKeys.get(i) : null; + ACTOR_RENDER_HOLDS.put( + actorUuid, + new ClientAnimationRuntime.RenderHold( + state.instanceId, + state.currentAnimationId(), + state.modelRootsForActor(i), + actorKey, + state.speed, + resolveActorBoneItems(state, i), + state.anchorOrientation, + expiresAt + ) + ); + } + } + } + } + + public static boolean hasActiveInstances() { + return HAS_ACTIVE_INSTANCES; + } + + public static boolean isActorActive(UUID actorUuid) { + return ACTIVE_ACTOR_LATEST_ANIMATION_ID.containsKey(actorUuid); + } + + public static boolean isActorPendingOrActive(UUID actorUuid) { + if (ACTIVE_ACTOR_LATEST_ANIMATION_ID.containsKey(actorUuid)) { + return true; + } + + for (ClientAnimationRuntime.PendingStart ps : PENDING_STARTS) { + for (UUID uuid : ps.actorUuids()) { + if (uuid.equals(actorUuid)) { + return true; + } + } + } + + return false; + } + + @Nullable + public static ClientAnimationRuntime.LockedOrientation getLockedOrientation(UUID actorUuid) { + ClientAnimationRuntime.LockedOrientation locked = LOCKED_ORIENTATION_BY_ACTOR.get(actorUuid); + if (locked != null) { + return locked; + } + + ClientAnimationRuntime.RenderHold hold = ACTOR_RENDER_HOLDS.get(actorUuid); + return hold != null && CURRENT_TICK <= hold.expiresAtTick() ? hold.lockedOrientation() : null; + } + + @Nullable + public static Vec3d findCameraOrbitTargetForActor(UUID actorUuid) { + if (actorUuid == null) { + return null; + } + + long now = CURRENT_TICK; + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null && state.isActive(now) && state.containsActor(actorUuid)) { + return state.cameraOrbitTarget; + } + } + + return null; + } + + @Nullable + public static UUID findAnchorUuidForActor(UUID actorUuid) { + for (ClientAnimationRuntime.InstanceState state : INSTANCES.values()) { + if (state.containsActor(actorUuid)) { + return state.anchorUuid; + } + } + + for (ClientAnimationRuntime.PendingStart ps : PENDING_STARTS) { + for (UUID uuid : ps.actorUuids()) { + if (uuid.equals(actorUuid)) { + return ps.anchorUuid(); + } + } + } + + return null; + } + + @Nullable + public static Long findStartTickForActor(UUID actorUuid) { + for (ClientAnimationRuntime.InstanceState state : INSTANCES.values()) { + if (state.containsActor(actorUuid)) { + return state.startTick; + } + } + + for (ClientAnimationRuntime.PendingStart ps : PENDING_STARTS) { + for (UUID uuid : ps.actorUuids()) { + if (uuid.equals(actorUuid)) { + return ps.startTick(); + } + } + } + + return null; + } + + public static Identifier findLatestActiveAnimationIdContaining(UUID actorUuid) { + return ACTIVE_ACTOR_LATEST_ANIMATION_ID.get(actorUuid); + } + + public static List findLatestModelRootsForActor(UUID actorUuid) { + List roots = ACTIVE_ACTOR_MODEL_ROOTS.get(actorUuid); + return roots == null ? List.of() : roots; + } + + public static String findLatestActiveActorKey(UUID actorUuid) { + return ACTIVE_ACTOR_LATEST_KEY.get(actorUuid); + } + + public static double findLatestSpeedForActor(UUID actorUuid) { + return ACTIVE_ACTOR_SPEED.getOrDefault(actorUuid, 1.0); + } + + public static Map findLatestBoneItemsForActor(UUID actorUuid) { + Map props = ACTIVE_ACTOR_BONE_ITEMS.get(actorUuid); + return props != null && !props.isEmpty() ? props : Map.of(); + } + + public static void setPropOverride(UUID instanceId, UUID actorUuid, ClientAnimationRuntime.PropSlot slot, ItemStack stack) { + if (instanceId != null && actorUuid != null && slot != null) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + EnumMap actorOverrides = state.propOverridesByActor + .computeIfAbsent(actorUuid, unused -> new EnumMap<>(ClientAnimationRuntime.PropSlot.class)); + if (stack != null && !stack.isEmpty()) { + actorOverrides.put(slot, stack.copy()); + } else { + actorOverrides.remove(slot); + } + + if (actorOverrides.isEmpty()) { + state.propOverridesByActor.remove(actorUuid); + } + } + } + } + + public static void setLeftHandPropOverride(UUID instanceId, UUID actorUuid, ItemStack stack) { + setPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.LEFT, stack); + } + + public static void setRightHandPropOverride(UUID instanceId, UUID actorUuid, ItemStack stack) { + setPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.RIGHT, stack); + } + + public static void setFloorPropOverride(UUID instanceId, UUID actorUuid, ItemStack stack) { + setPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.FLOOR, stack); + } + + public static void clearPropOverride(UUID instanceId, UUID actorUuid, ClientAnimationRuntime.PropSlot slot) { + if (instanceId != null && actorUuid != null && slot != null) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + EnumMap actorOverrides = state.propOverridesByActor.get(actorUuid); + if (actorOverrides != null) { + actorOverrides.remove(slot); + if (actorOverrides.isEmpty()) { + state.propOverridesByActor.remove(actorUuid); + } + } + } + } + } + + public static void clearLeftHandPropOverride(UUID instanceId, UUID actorUuid) { + clearPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.LEFT); + } + + public static void clearRightHandPropOverride(UUID instanceId, UUID actorUuid) { + clearPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.RIGHT); + } + + public static void clearFloorPropOverride(UUID instanceId, UUID actorUuid) { + clearPropOverride(instanceId, actorUuid, ClientAnimationRuntime.PropSlot.FLOOR); + } + + public static void clearPropOverrides(UUID instanceId, UUID actorUuid) { + if (instanceId != null && actorUuid != null) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + state.propOverridesByActor.remove(actorUuid); + } + } + } + + public static UUID findLatestActiveInstanceContaining(UUID actorUuid) { + long now = CURRENT_TICK; + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null && state.isActive(now) && state.containsActor(actorUuid)) { + return id; + } + } + + ClientAnimationRuntime.RenderHold hold = ACTOR_RENDER_HOLDS.get(actorUuid); + return hold != null && now <= hold.expiresAtTick() ? hold.instanceId() : null; + } + + public static boolean shouldHideNameLabelForViewer(UUID viewerUuid, UUID renderedUuid) { + if (viewerUuid != null && renderedUuid != null && !viewerUuid.equals(renderedUuid)) { + UUID viewerInstance = findLatestActiveInstanceContaining(viewerUuid); + if (viewerInstance == null) { + return false; + } + + UUID renderedInstance = findLatestActiveInstanceContaining(renderedUuid); + return viewerInstance.equals(renderedInstance); + } else { + return false; + } + } + + public static double findOutsideViewerNameLabelYOffset(UUID viewerUuid, UUID renderedUuid) { + if (renderedUuid == null) { + return 0.0; + } + + ClientAnimationRuntime.InstanceState state = findLatestActiveStateContaining(renderedUuid); + if (state == null) { + return 0.0; + } + + if (viewerUuid != null && state.containsActor(viewerUuid)) { + return 0.0; + } + + int actorIndex = state.actorUuids.indexOf(renderedUuid); + return actorIndex <= 0 ? 0.0 : Math.min(1.5, actorIndex * 0.28); + } + + private static ClientAnimationRuntime.InstanceState findLatestActiveStateContaining(UUID actorUuid) { + if (actorUuid == null) { + return null; + } + + long now = CURRENT_TICK; + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null && state.isActive(now) && state.containsActor(actorUuid)) { + return state; + } + } + + return null; + } + + public static AnimationStageInfo findCurrentStage(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + return state == null ? null : state.currentStage(); + } + + public static AnimationStageInfo findStage(UUID instanceId, int stageIndex) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + return state != null && stageIndex >= 0 && stageIndex < state.stages.size() ? state.stages.get(stageIndex) : null; + } + + public static ClientAnimationRuntime.StageTimelineSnapshot findStageTimeline(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + return state == null + ? null + : new ClientAnimationRuntime.StageTimelineSnapshot(state.stages, state.stageIndex, state.stageStartTick, state.stageEndTick, state.speed); + } + + public static long findStageStartTick(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + return state == null ? -1L : state.stageStartTick; + } + + public static long findStageEndTick(UUID instanceId) { + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + return state == null ? -1L : state.stageEndTick; + } + + public static Double findWarpedAuthoredTimelineSeconds(UUID actorUuid, float tickDelta) { + if (actorUuid == null) { + return null; + } + + long now = CURRENT_TICK; + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null && state.isActive(now) && state.containsActor(actorUuid)) { + AnimationStageInfo stage = state.currentStage(); + double cycleTicks = exactCycleTicks(stage); + if (stage != null && stage.loop() && !(cycleTicks <= 0.0)) { + double offsetTicks = StageTimeWarp.offsetSecondsToTicks(stage.cycleMidpointOffsetSeconds()); + if (!StageTimeWarp.hasWarp(cycleTicks, offsetTicks)) { + return null; + } + + double warpedTicks = Math.max(0.0, ((float)now + Math.max(0.0F, tickDelta) - (float)state.stageStartTick) * state.speed); + double authoredTicks = StageTimeWarp.warpedToAuthoredCycleTicks(warpedTicks, cycleTicks, offsetTicks); + return authoredTicks / 20.0; + } + + return null; + } + } + + return null; + } + + public static UUID findLatestActiveInstance() { + long now = CURRENT_TICK; + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID id = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(id); + if (state != null && state.isActive(now)) { + return id; + } + } + + return null; + } + + public static boolean isAnimationLooping(Identifier animationId) { + if (animationId == null) { + return true; + } + + Boolean loop = KNOWN_LOOP_BY_ANIMATION_ID.get(animationId); + return loop != null ? loop : true; + } + + private static List normalizeStages(Identifier baseAnimationId, List stages) { + return stages != null && !stages.isEmpty() ? List.copyOf(stages) : List.of(new AnimationStageInfo(baseAnimationId, true, 0L, true, 1.0)); + } + + private static double sanitizeSpeed(double speed) { + return !Double.isNaN(speed) && !Double.isInfinite(speed) && !(speed <= 0.0) ? Math.max(0.1, Math.min(speed, 4.0)) : 1.0; + } + + private static double exactCycleTicks(AnimationStageInfo stage) { + if (stage == null) { + return 0.0; + } + + double exact = stage.cycleTicks(); + return Double.isFinite(exact) && exact > 0.0 ? exact : Math.max(0L, stage.lengthTicks()); + } + + private static Vec3d sanitizeCameraOrbitTarget(Vec3d target) { + return target != null && target.isFinite() ? target : null; + } + + private static void applySpeedToState(ClientAnimationRuntime.InstanceState state, ClientAnimationRuntime.PendingSpeedUpdate update) { + state.speed = update.speed(); + state.stageStartTick = Math.max(state.startTick, update.stageStartTick()); + state.stageEndTick = update.stageEndTick(); + } + + private static void rememberStageMeta(Identifier baseAnimationId, List stages) { + if (stages != null && !stages.isEmpty()) { + for (AnimationStageInfo stage : stages) { + if (stage != null && stage.animationId() != null) { + KNOWN_LOOP_BY_ANIMATION_ID.put(stage.animationId(), stage.loop()); + Identifier playbackId = stage.effectiveAnimationId(); + if (playbackId != null) { + KNOWN_LOOP_BY_ANIMATION_ID.put(playbackId, stage.loop()); + } + } + } + } else { + KNOWN_LOOP_BY_ANIMATION_ID.putIfAbsent(baseAnimationId, true); + } + } + + private static Map resolveActorBoneItems(ClientAnimationRuntime.InstanceState state, int actorIndex) { + if (state != null && actorIndex >= 0 && actorIndex < state.actorUuids.size()) { + String actorKey = actorIndex < state.actorKeys.size() ? state.actorKeys.get(actorIndex) : null; + UUID actorUuid = state.actorUuids.get(actorIndex); + LinkedHashMap out = new LinkedHashMap<>(); + AnimationStageInfo.ActorPropEntry stageProps = state.currentStage().actorPropsForKey(actorKey); + if (stageProps != null) { + NonAnimationRenderEvents.BoneItemProp left = stageProps.leftBoneProp(); + NonAnimationRenderEvents.BoneItemProp right = stageProps.rightBoneProp(); + NonAnimationRenderEvents.BoneItemProp floor = stageProps.floorBoneProp(); + if (left != null) { + out.put("propleft", left); + } + + if (right != null) { + out.put("propright", right); + } + + if (floor != null) { + out.put("propfloor", floor); + } + } + + EnumMap actorOverrides = state.propOverridesByActor.get(actorUuid); + if (actorOverrides != null && !actorOverrides.isEmpty()) { + for (Entry entry : actorOverrides.entrySet()) { + ClientAnimationRuntime.PropSlot slot = entry.getKey(); + ItemStack stack = entry.getValue(); + if (slot != null) { + if (stack != null && !stack.isEmpty()) { + out.put(slot.boneName(), new NonAnimationRenderEvents.BoneItemProp(stack, slot.displayContext())); + } else { + out.remove(slot.boneName()); + } + } + } + } + + return out.isEmpty() ? Map.of() : Map.copyOf(out); + } else { + return Map.of(); + } + } + + private static void syncLockedOrientations(MinecraftClient client) { + if (client != null && client.world != null) { + if (ACTIVE_ACTOR_LATEST_ANIMATION_ID.isEmpty()) { + LOCKED_ORIENTATION_BY_ACTOR.clear(); + } else { + Map rebuilt = new HashMap<>(); + Iterator it = START_ORDER.descendingIterator(); + + while (it.hasNext()) { + UUID instanceId = it.next(); + ClientAnimationRuntime.InstanceState state = INSTANCES.get(instanceId); + if (state != null) { + if (state.anchorOrientation == null) { + state.anchorOrientation = resolveOrientation(client, state.anchorUuid); + } + + ClientAnimationRuntime.LockedOrientation anchor = state.anchorOrientation; + + for (UUID actorUuid : state.actorUuids) { + if (ACTIVE_ACTOR_LATEST_ANIMATION_ID.containsKey(actorUuid) && !rebuilt.containsKey(actorUuid)) { + if (anchor != null) { + rebuilt.put(actorUuid, anchor); + } else if (client.world.getEntity(actorUuid) instanceof LivingEntity living) { + rebuilt.put(actorUuid, new ClientAnimationRuntime.LockedOrientation(living.getBodyYaw(), living.getHeadYaw(), living.getPitch())); + } + } + } + } + } + + LOCKED_ORIENTATION_BY_ACTOR.clear(); + LOCKED_ORIENTATION_BY_ACTOR.putAll(rebuilt); + } + } + } + + private static ClientAnimationRuntime.LockedOrientation resolveOrientation(MinecraftClient client, UUID uuid) { + if (uuid != null && client != null && client.world != null) { + return client.world.getEntity(uuid) instanceof LivingEntity living + ? new ClientAnimationRuntime.LockedOrientation(living.getBodyYaw(), living.getHeadYaw(), living.getPitch()) + : null; + } else { + return null; + } + } + + private static void tickSoundCues(MinecraftClient client, long now) { + if (client != null && client.world != null) { + if (!INSTANCES.isEmpty()) { + if (AnimationSoundEffects.hasAny()) { + ClientWorld world = client.world; + double lookaheadWorldTicks = AnimationSoundAnticipation.lookaheadMillis() / 50.0; + + for (ClientAnimationRuntime.InstanceState state : INSTANCES.values()) { + if (now >= state.startTick && now < state.effectiveStopTick() && !state.actorUuids.isEmpty()) { + if (state.soundStageIndex != state.stageIndex) { + state.soundStageIndex = state.stageIndex; + state.lastSoundTick = -1.0; + } + + if (state.anticipatedSoundStageIndex != state.stageIndex) { + state.anticipatedSoundStageIndex = state.stageIndex; + state.lastAnticipatedSoundTick = -1.0; + } + + AnimationStageInfo currentStage = state.currentStage(); + Identifier stageAnimationId = state.currentAnimationId(); + double animTicks = Math.max(0.0, (now - state.stageStartTick) * state.speed); + ClientAnimationRuntime.MergedSoundTrack soundTrack = resolveMergedSoundTrack(world, state, stageAnimationId, currentStage); + if (soundTrack != null && !soundTrack.cues().isEmpty()) { + double anticipatedWorldTick = now + lookaheadWorldTicks; + if (state.stageEndTick > state.stageStartTick && anticipatedWorldTick >= state.stageEndTick) { + anticipatedWorldTick = Math.nextDown(state.stageEndTick); + } + + double anticipatedTicks = Math.max(0.0, (anticipatedWorldTick - state.stageStartTick) * state.speed); + fireAnticipatedCues( + soundTrack.anchor(), + state, + soundTrack.cues(), + stageAnimationId, + state.stageIndex, + currentStage.loop(), + state.speed, + state.lastAnticipatedSoundTick, + anticipatedTicks, + soundTrack.cycleTicks() + ); + state.lastAnticipatedSoundTick = Math.max(state.lastAnticipatedSoundTick, anticipatedTicks); + fireSoundCues( + soundTrack.anchor(), + state, + soundTrack.cues(), + stageAnimationId, + currentStage.loop(), + state.lastSoundTick, + animTicks, + soundTrack.cycleTicks() + ); + state.lastSoundTick = animTicks; + } + + anticipateNextStageCues(world, state, now, lookaheadWorldTicks); + } + } + } + } + } + } + + private static ClientAnimationRuntime.MergedSoundTrack resolveMergedSoundTrack( + ClientWorld world, ClientAnimationRuntime.InstanceState state, Identifier stageAnimationId, AnimationStageInfo stage + ) { + if (world != null && state != null && stageAnimationId != null && stage != null) { + int count = state.actorUuids.size(); + if (count == 0) { + return null; + } + + LivingEntity anchor = resolveSoundAnchor(world, state); + if (anchor == null) { + return null; + } + + List rawCues = new ArrayList<>(); + double maxLengthTicks = 0.0; + + for (int index : soundActorOrder(state)) { + ClientAnimationRuntime.SoundTrackCandidate candidate = resolveSoundCandidateForIndex(world, state, stageAnimationId, index); + if (candidate != null) { + maxLengthTicks = Math.max(maxLengthTicks, candidate.soundAnimation().lengthTicks()); + + for (AnimationSoundEffects.SoundCue cue : candidate.soundAnimation().cues()) { + if (cue != null) { + rawCues.add(new ClientAnimationRuntime.MergedSoundCue(candidate.animationResource(), candidate.animationKey(), cue, cue.timeTicks())); + } + } + } + } + + if (rawCues.isEmpty()) { + return null; + } + + double exactStageCycleTicks = exactCycleTicks(stage); + double cycleTicks = exactStageCycleTicks > 0.0 ? exactStageCycleTicks : maxLengthTicks; + double offsetTicks = StageTimeWarp.offsetSecondsToTicks(stage.cycleMidpointOffsetSeconds()); + boolean warp = stage.loop() && StageTimeWarp.hasWarp(cycleTicks, offsetTicks); + Map byCue = new LinkedHashMap<>(); + + for (ClientAnimationRuntime.MergedSoundCue rawCue : rawCues) { + double cueTimeTicks = warp ? warpCueTick(rawCue.cue().timeTicks(), cycleTicks, offsetTicks) : rawCue.cue().timeTicks(); + long timeKey = soundCueTimeKey(cueTimeTicks); + ClientAnimationRuntime.SoundCueMergeKey mergeKey = new ClientAnimationRuntime.SoundCueMergeKey( + timeKey, AnimationCueEffects.timingFamily(rawCue.cue().effect()), rawCue.cue().randomSounds() + ); + byCue.putIfAbsent( + mergeKey, new ClientAnimationRuntime.MergedSoundCue(rawCue.animationResource(), rawCue.animationKey(), rawCue.cue(), cueTimeTicks) + ); + } + + if (byCue.isEmpty()) { + return null; + } + + List cues = new ArrayList<>(byCue.values()); + cues.sort(Comparator.comparingDouble(ClientAnimationRuntime.MergedSoundCue::timeTicks)); + return new ClientAnimationRuntime.MergedSoundTrack(anchor, List.copyOf(cues), cycleTicks); + } else { + return null; + } + } + + private static LivingEntity resolveSoundAnchor(ClientWorld world, ClientAnimationRuntime.InstanceState state) { + if (world != null && state != null) { + if (state.anchorUuid != null && world.getEntity(state.anchorUuid) instanceof LivingEntity living) { + return living; + } + + for (UUID actorId : state.actorUuids) { + if (world.getEntity(actorId) instanceof LivingEntity living) { + return living; + } + } + + return null; + } else { + return null; + } + } + + private static List soundActorOrder(ClientAnimationRuntime.InstanceState state) { + if (state != null && !state.actorUuids.isEmpty()) { + List order = new ArrayList<>(state.actorUuids.size()); + int anchorIndex = -1; + if (state.anchorUuid != null) { + for (int i = 0; i < state.actorUuids.size(); i++) { + if (state.actorUuids.get(i).equals(state.anchorUuid)) { + anchorIndex = i; + break; + } + } + } + + if (anchorIndex >= 0) { + order.add(anchorIndex); + } + + for (int i = 0; i < state.actorUuids.size(); i++) { + if (i != anchorIndex) { + order.add(i); + } + } + + return order; + } else { + return List.of(); + } + } + + private static long soundCueTimeKey(double timeTicks) { + return !Double.isFinite(timeTicks) ? Long.MIN_VALUE : Math.round(timeTicks * 1000.0); + } + + private static double warpCueTick(double cueTick, double cycleTicks, double offsetTicks) { + if (!StageTimeWarp.hasWarp(cycleTicks, offsetTicks)) { + return cueTick; + } + + double warped = StageTimeWarp.authoredToWarpedCycleTicks(cueTick, cycleTicks, offsetTicks); + return cueTick > 0.0 && Math.abs(cueTick - cycleTicks) < 1.0E-4 ? cycleTicks : warped; + } + + private static ClientAnimationRuntime.SoundTrackCandidate resolveSoundCandidateForIndex( + ClientWorld world, ClientAnimationRuntime.InstanceState state, Identifier stageAnimationId, int index + ) { + if (world == null || state == null || stageAnimationId == null) { + return null; + } + + if (index >= 0 && index < state.actorUuids.size()) { + UUID actorId = state.actorUuids.get(index); + if (world.getEntity(actorId) instanceof LivingEntity anchor) { + Identifier typeId = Registries.ENTITY_TYPE.getId(anchor.getType()); + String actorKey = index < state.actorKeys.size() ? state.actorKeys.get(index) : null; + ClientAnimationRuntime.SoundTrackCandidate candidate = resolveSoundCandidateForParams(stageAnimationId, actorKey, typeId, true); + if (candidate != null) { + return candidate; + } + + if (anchor instanceof PlayerEntity) { + Identifier playerType = Identifier.of("minecraft", "player"); + candidate = resolveSoundCandidateForParams(stageAnimationId, "player", playerType, false); + if (candidate != null) { + return candidate; + } + } + + return null; + } else { + return null; + } + } else { + return null; + } + } + + private static ClientAnimationRuntime.SoundTrackCandidate resolveSoundCandidateForParams( + Identifier stageAnimationId, String actorKey, Identifier typeId, boolean warnMissing + ) { + if (stageAnimationId == null) { + return null; + } + + Identifier animationResource = GeckoResourceResolver.resolveAnimationResource(stageAnimationId, actorKey, typeId, warnMissing); + if (animationResource == null) { + return null; + } + + String animationKey = GeckoResourceResolver.resolveAnimationKey(animationResource, stageAnimationId, actorKey, typeId, warnMissing); + if (animationKey == null) { + return null; + } + + AnimationSoundEffects.SoundAnimation soundAnimation = AnimationSoundEffects.getSoundAnimation(animationResource, animationKey); + return soundAnimation != null && !soundAnimation.cues().isEmpty() + ? new ClientAnimationRuntime.SoundTrackCandidate(animationResource, animationKey, soundAnimation) + : null; + } + + private static void anticipateNextStageCues(ClientWorld world, ClientAnimationRuntime.InstanceState state, long now, double lookaheadWorldTicks) { + if (!(lookaheadWorldTicks <= 0.0)) { + if (state.stageEndTick > state.stageStartTick) { + if (AnimationSoundAnticipation.allowNextStageLookahead(state.currentStage().animationId(), state.stageIndex)) { + double anticipatedWorldTick = now + lookaheadWorldTicks; + if (!(anticipatedWorldTick < state.stageEndTick)) { + int nextStageIndex = state.stageIndex + 1; + if (nextStageIndex < state.stages.size()) { + if (state.anticipatedNextSoundStageIndex != nextStageIndex) { + state.anticipatedNextSoundStageIndex = nextStageIndex; + state.lastAnticipatedNextSoundTick = -1.0; + } + + AnimationStageInfo nextStage = state.stages.get(nextStageIndex); + Identifier nextAnimationId = nextStage.effectiveAnimationId(); + ClientAnimationRuntime.MergedSoundTrack nextTrack = resolveMergedSoundTrack(world, state, nextAnimationId, nextStage); + if (nextTrack != null && !nextTrack.cues().isEmpty()) { + double nextSpeed = sanitizeSpeed(nextStage.speed()); + double anticipatedTicks = Math.max(0.0, (anticipatedWorldTick - state.stageEndTick) * nextSpeed); + if (!nextStage.loop() && nextTrack.cycleTicks() > 0.0) { + anticipatedTicks = Math.min(anticipatedTicks, nextTrack.cycleTicks()); + } + + fireAnticipatedCues( + nextTrack.anchor(), + state, + nextTrack.cues(), + nextAnimationId, + nextStageIndex, + nextStage.loop(), + nextSpeed, + state.lastAnticipatedNextSoundTick, + anticipatedTicks, + nextTrack.cycleTicks() + ); + state.lastAnticipatedNextSoundTick = Math.max(state.lastAnticipatedNextSoundTick, anticipatedTicks); + } + } + } + } + } + } + } + + private static void fireAnticipatedCues( + LivingEntity anchor, + ClientAnimationRuntime.InstanceState state, + List cues, + Identifier stageAnimationId, + int stageIndex, + boolean loop, + double speed, + double lastTicks, + double anticipatedTicks, + double cycleTicks + ) { + if (!(lastTicks >= 0.0) || !(anticipatedTicks <= lastTicks + 1.0E-4)) { + visitSoundCues( + cues, + loop, + lastTicks, + anticipatedTicks, + cycleTicks, + mergedCue -> anticipateSoundCue(anchor, state, mergedCue, stageAnimationId, cues, stageIndex, speed, loop, cycleTicks) + ); + } + } + + private static void anticipateSoundCue( + LivingEntity anchor, + ClientAnimationRuntime.InstanceState state, + ClientAnimationRuntime.MergedSoundCue mergedCue, + Identifier stageAnimationId, + List cues, + int stageIndex, + double speed, + boolean loop, + double cycleTicks + ) { + if (mergedCue != null && mergedCue.cue() != null) { + String effect = mergedCue.cue().effect(); + if (effect != null && !effect.isBlank()) { + AnimationSoundAnticipation.anticipate( + createSoundContext(anchor, state, mergedCue, stageAnimationId, cues, stageIndex, speed, effect, loop, cycleTicks) + ); + } + } + } + + private static void fireSoundCues( + LivingEntity anchor, + ClientAnimationRuntime.InstanceState state, + List cues, + Identifier stageAnimationId, + boolean loop, + double lastTicks, + double animTicks, + double cycleTicks + ) { + visitSoundCues( + cues, loop, lastTicks, animTicks, cycleTicks, mergedCue -> playSoundCue(anchor, state, mergedCue, stageAnimationId, cues, loop, cycleTicks) + ); + } + + private static void visitSoundCues( + List cues, + boolean loop, + double lastTicks, + double animTicks, + double cycleTicks, + ClientAnimationRuntime.SoundCueVisitor visitor + ) { + if (loop && !(cycleTicks <= 0.0)) { + long lastCycle = lastTicks < 0.0 ? -1L : (long)Math.floor(lastTicks / cycleTicks); + long currentCycle = (long)Math.floor(animTicks / cycleTicks); + double lastCyclePos = lastCycle < 0L ? -1.0 : lastTicks - lastCycle * cycleTicks; + double currentCyclePos = animTicks - currentCycle * cycleTicks; + if (lastCycle < 0L) { + lastCycle = currentCycle; + } + + if (currentCycle == lastCycle) { + visitSoundCueWindow(cues, lastCyclePos, currentCyclePos, visitor); + } else { + visitSoundCueWindow(cues, lastCyclePos, cycleTicks, visitor); + long maxExtraCycles = Math.min(2L, currentCycle - lastCycle - 1L); + + for (long i = 0L; i < maxExtraCycles; i++) { + visitSoundCueWindow(cues, -1.0, cycleTicks, visitor); + } + + visitSoundCueWindow(cues, -1.0, currentCyclePos, visitor); + } + } else { + visitSoundCueWindow(cues, lastTicks, animTicks, visitor); + } + } + + private static void visitSoundCueWindow( + List cues, double fromTick, double toTick, ClientAnimationRuntime.SoundCueVisitor visitor + ) { + for (ClientAnimationRuntime.MergedSoundCue mergedCue : cues) { + double cueTick = mergedCue.timeTicks(); + if (!(cueTick > toTick) && (!(fromTick >= 0.0) || !(cueTick <= fromTick))) { + visitor.visit(mergedCue); + } + } + } + + private static void playSoundCue( + LivingEntity anchor, + ClientAnimationRuntime.InstanceState state, + ClientAnimationRuntime.MergedSoundCue mergedCue, + Identifier stageAnimationId, + List cues, + boolean loop, + double cycleTicks + ) { + if (mergedCue != null && mergedCue.cue() != null) { + AnimationSoundEffects.SoundCue cue = mergedCue.cue(); + String effect = selectCueEffect(anchor, cue); + if (effect != null && !effect.isBlank()) { + NonAnimationSoundEvents.SoundContext context = createSoundContext( + anchor, state, mergedCue, stageAnimationId, cues, state.stageIndex, state.speed, effect, loop, cycleTicks + ); + NonAnimationSoundEvents.SoundOverride override = ((NonAnimationSoundEvents.SoundResolver)NonAnimationSoundEvents.RESOLVE.invoker()) + .resolve(context); + if (override != null) { + if (override.cancel()) { + return; + } + + Identifier overrideId = override.soundId(); + if (overrideId != null) { + playSound(anchor, overrideId, override.volume(), override.pitch()); + return; + } + } + + Identifier fallback = resolveEffectAsSoundId(effect, stageAnimationId, mergedCue.animationResource()); + if (fallback != null) { + playSound(anchor, fallback, cue.volume(), cue.pitch()); + } + } + } + } + + private static NonAnimationSoundEvents.SoundContext createSoundContext( + LivingEntity anchor, + ClientAnimationRuntime.InstanceState state, + ClientAnimationRuntime.MergedSoundCue mergedCue, + Identifier stageAnimationId, + List cues, + int stageIndex, + double speed, + String effect, + boolean loop, + double cycleTicks + ) { + AnimationSoundEffects.SoundCue cue = mergedCue.cue(); + double nextDelayTicks = nextSameEffectCueDelayTicks(cues, mergedCue, loop, cycleTicks); + double safeSpeed = Math.max(1.0E-4, sanitizeSpeed(speed)); + double nextDelayMs = nextDelayTicks > 0.0 ? nextDelayTicks / safeSpeed * 50.0 : 0.0; + return new NonAnimationSoundEvents.SoundContext( + anchor, + stageAnimationId, + mergedCue.animationResource(), + mergedCue.animationKey(), + effect, + mergedCue.timeTicks() / 20.0, + cue.volume(), + cue.pitch(), + state.instanceId, + stageIndex, + loop, + mergedCue.timeTicks(), + cycleTicks, + safeSpeed, + nextDelayTicks, + nextDelayMs + ); + } + + private static String selectCueEffect(LivingEntity anchor, AnimationSoundEffects.SoundCue cue) { + if (cue == null) { + return null; + } else { + List sounds = cue.randomSounds(); + if (sounds != null && !sounds.isEmpty()) { + int index = anchor.getEntityWorld().getRandom().nextInt(sounds.size()); + return sounds.get(index).toString(); + } else { + return cue.effect(); + } + } + } + + private static double nextSameEffectCueDelayTicks( + List cues, ClientAnimationRuntime.MergedSoundCue currentCue, boolean loop, double cycleTicks + ) { + if (cues != null && !cues.isEmpty() && currentCue != null) { + if (currentCue.cue() == null) { + return 0.0; + } + + String effectFamily = AnimationCueEffects.timingFamily(currentCue.cue().effect()); + if (effectFamily == null) { + return 0.0; + } + + double currentTick = currentCue.timeTicks(); + double epsilon = 1.0E-4; + ClientAnimationRuntime.MergedSoundCue firstSame = null; + + for (ClientAnimationRuntime.MergedSoundCue candidate : cues) { + if (candidate != null && candidate.cue() != null) { + AnimationSoundEffects.SoundCue cue = candidate.cue(); + if (effectFamily.equals(AnimationCueEffects.timingFamily(cue.effect()))) { + if (firstSame == null || candidate.timeTicks() < firstSame.timeTicks()) { + firstSame = candidate; + } + + if (candidate.timeTicks() > currentTick + 1.0E-4) { + return Math.max(0.0, candidate.timeTicks() - currentTick); + } + } + } + } + + if (loop && !(cycleTicks <= 0.0) && firstSame != null) { + double wrappedDelay = cycleTicks - currentTick + firstSame.timeTicks(); + if (wrappedDelay <= 1.0E-4) { + wrappedDelay = cycleTicks; + } + + return Math.max(0.0, wrappedDelay); + } else { + return 0.0; + } + } else { + return 0.0; + } + } + + private static Identifier resolveEffectAsSoundId(String effect, Identifier stageAnimationId, Identifier animationResource) { + if (effect != null && !effect.isBlank()) { + Identifier direct = Identifier.tryParse(effect); + if (direct != null) { + return direct; + } + + String namespace = null; + if (stageAnimationId != null) { + namespace = stageAnimationId.getNamespace(); + } else if (animationResource != null) { + namespace = animationResource.getNamespace(); + } + + return namespace != null && !namespace.isBlank() ? Identifier.tryParse(namespace + ":" + effect) : null; + } else { + return null; + } + } + + private static void playSound(LivingEntity anchor, Identifier soundId, float volume, float pitch) { + if (anchor != null && soundId != null) { + ClientWorld world = anchor.getEntityWorld() instanceof ClientWorld clientWorld ? clientWorld : null; + if (world != null) { + SoundEvent soundEvent = Registries.SOUND_EVENT.containsId(soundId) ? (SoundEvent)Registries.SOUND_EVENT.get(soundId) : SoundEvent.of(soundId); + SoundCategory category = anchor instanceof PlayerEntity ? SoundCategory.PLAYERS : SoundCategory.NEUTRAL; + world.playSoundFromEntityClient(anchor, soundEvent, category, volume, pitch); + } + } + } + + private static final class InstanceState { + final UUID instanceId; + final Identifier animationId; + final List> modelRootsByActor; + final List actorUuids; + final Set actorUuidSet; + final List actorKeys; + final UUID anchorUuid; + ClientAnimationRuntime.LockedOrientation anchorOrientation; + final Vec3d cameraOrbitTarget; + final long startTick; + final List stages; + int stageIndex = 0; + long stageStartTick; + long stageEndTick; + int manualAdvanceRequests = 0; + double speed; + int soundStageIndex = -1; + double lastSoundTick = -1.0; + int anticipatedSoundStageIndex = -1; + double lastAnticipatedSoundTick = -1.0; + int anticipatedNextSoundStageIndex = -1; + double lastAnticipatedNextSoundTick = -1.0; + final Map> propOverridesByActor = new HashMap<>(); + Long stopAtTick; + + InstanceState( + UUID instanceId, + Identifier animationId, + List> modelRootsByActor, + List actorUuids, + List actorKeys, + List stages, + long startTick, + int initialStageIndex, + long initialStageStartTick, + long initialStageEndTick, + double speed, + UUID anchorUuid, + ClientAnimationRuntime.LockedOrientation anchorOrientation, + Vec3d cameraOrbitTarget + ) { + this.instanceId = instanceId; + this.animationId = animationId; + this.actorUuids = List.copyOf(actorUuids); + this.modelRootsByActor = modelRootsByActor == null + ? Collections.nCopies(this.actorUuids.size(), List.of()) + : modelRootsByActor.stream().map(roots -> roots == null ? List.of() : List.copyOf((Collection)roots)).toList(); + if (this.modelRootsByActor.size() != this.actorUuids.size()) { + throw new IllegalArgumentException("modelRootsByActor size mismatch: " + this.modelRootsByActor.size()); + } + + this.actorUuidSet = Set.copyOf(this.actorUuids); + this.actorKeys = actorKeys == null ? List.of() : List.copyOf(actorKeys); + this.startTick = startTick; + this.stages = ClientAnimationRuntime.normalizeStages(animationId, stages); + this.stageIndex = this.stages.isEmpty() ? 0 : Math.max(0, Math.min(initialStageIndex, this.stages.size() - 1)); + this.stageStartTick = Math.max(startTick, initialStageStartTick); + this.stageEndTick = initialStageEndTick; + this.anchorUuid = anchorUuid; + this.anchorOrientation = anchorOrientation; + this.cameraOrbitTarget = cameraOrbitTarget; + this.speed = ClientAnimationRuntime.sanitizeSpeed(speed); + } + + long effectiveStopTick() { + return this.stopAtTick != null ? this.stopAtTick : Long.MAX_VALUE; + } + + boolean isActive(long nowTick) { + return nowTick < this.startTick ? false : nowTick < this.effectiveStopTick(); + } + + boolean containsActor(UUID actorUuid) { + return this.actorUuidSet.contains(actorUuid); + } + + List modelRootsForActor(int actorIndex) { + return actorIndex >= 0 && actorIndex < this.modelRootsByActor.size() ? this.modelRootsByActor.get(actorIndex) : List.of(); + } + + AnimationStageInfo currentStage() { + return this.stages.isEmpty() ? new AnimationStageInfo(this.animationId, true, 0L, true, 1.0) : this.stages.get(this.stageIndex); + } + + Identifier currentAnimationId() { + return this.currentStage().effectiveAnimationId(); + } + + boolean advanceStage(long nextStageStartTick) { + if (this.stageIndex + 1 >= this.stages.size()) { + return false; + } + + this.stageIndex++; + this.stageStartTick = nextStageStartTick; + this.stageEndTick = -1L; + this.speed = ClientAnimationRuntime.sanitizeSpeed(this.stages.get(this.stageIndex).speed()); + this.resetSoundTimelinesForStage(this.stageIndex); + return true; + } + + boolean setStageIndex(int targetStageIndex, long nextStageStartTick, long nextStageEndTick) { + if (targetStageIndex >= 0 && targetStageIndex < this.stages.size()) { + this.stageIndex = targetStageIndex; + this.stageStartTick = nextStageStartTick; + this.stageEndTick = nextStageEndTick; + this.speed = ClientAnimationRuntime.sanitizeSpeed(this.stages.get(this.stageIndex).speed()); + this.resetSoundTimelinesForStage(this.stageIndex); + return true; + } else { + return false; + } + } + + private void resetSoundTimelinesForStage(int nextStageIndex) { + this.soundStageIndex = nextStageIndex; + this.lastSoundTick = -1.0; + if (this.anticipatedNextSoundStageIndex == nextStageIndex) { + this.anticipatedSoundStageIndex = nextStageIndex; + this.lastAnticipatedSoundTick = this.lastAnticipatedNextSoundTick; + } else { + this.anticipatedSoundStageIndex = nextStageIndex; + this.lastAnticipatedSoundTick = -1.0; + } + + this.anticipatedNextSoundStageIndex = -1; + this.lastAnticipatedNextSoundTick = -1.0; + } + } + + public record LockedOrientation(float bodyYaw, float headYaw, float pitch) { + } + + private record MergedSoundCue(Identifier animationResource, String animationKey, AnimationSoundEffects.SoundCue cue, double timeTicks) { + } + + private record MergedSoundTrack(LivingEntity anchor, List cues, double cycleTicks) { + } + + private record PendingSpeedUpdate(double speed, long stageStartTick, long stageEndTick) { + } + + private record PendingStageAdvance(UUID instanceId, long advanceTick, long stageEndTick, int stageIndex) { + } + + private record PendingStart( + UUID instanceId, + Identifier animationId, + List> modelRootsByActor, + List actorUuids, + List actorKeys, + List stages, + long startTick, + int stageIndex, + long stageStartTick, + long stageEndTick, + double speed, + UUID anchorUuid, + boolean lockOrientation, + ClientAnimationRuntime.LockedOrientation lockedOrientation, + Vec3d cameraOrbitTarget + ) { + } + + public enum PropSlot { + LEFT("propleft", ItemDisplayContext.THIRD_PERSON_LEFT_HAND), + RIGHT("propright", ItemDisplayContext.THIRD_PERSON_RIGHT_HAND), + FLOOR("propfloor", ItemDisplayContext.GROUND); + + private final String boneName; + private final ItemDisplayContext displayContext; + + PropSlot(String boneName, ItemDisplayContext displayContext) { + this.boneName = boneName; + this.displayContext = displayContext; + } + + public String boneName() { + return this.boneName; + } + + public ItemDisplayContext displayContext() { + return this.displayContext; + } + } + + private record RenderHold( + UUID instanceId, + Identifier animationId, + List modelRoots, + String actorKey, + double speed, + Map boneItems, + ClientAnimationRuntime.LockedOrientation lockedOrientation, + long expiresAtTick + ) { + } + + private record SoundCueMergeKey(long timeKey, @Nullable String effectFamily, List randomSounds) { + private SoundCueMergeKey { + randomSounds = randomSounds == null ? List.of() : List.copyOf(randomSounds); + } + } + + @FunctionalInterface + private interface SoundCueVisitor { + void visit(ClientAnimationRuntime.MergedSoundCue var1); + } + + private record SoundTrackCandidate(Identifier animationResource, String animationKey, AnimationSoundEffects.SoundAnimation soundAnimation) { + } + + public record StageTimelineSnapshot(List stages, int stageIndex, long stageStartTick, long stageEndTick, double speed) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundAnticipation.java b/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundAnticipation.java new file mode 100644 index 0000000..5ba45b1 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundAnticipation.java @@ -0,0 +1,56 @@ +package com.nonid.internal.animation.client.sound; + +import com.nonid.api.client.NonAnimationSoundEvents; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationSoundAnticipation { + private static final int MAX_LOOKAHEAD_MILLIS = 1000; + private static final AnimationSoundAnticipation.Listener NO_OP = new AnimationSoundAnticipation.Listener() { + @Override + public int lookaheadMillis() { + return 0; + } + + @Override + public void anticipate(NonAnimationSoundEvents.SoundContext context) { + } + + @Override + public boolean allowNextStageLookahead(Identifier currentStageId, int stageIndex) { + return true; + } + }; + private static volatile AnimationSoundAnticipation.Listener listener = NO_OP; + + private AnimationSoundAnticipation() { + } + + public static void setListener(@Nullable AnimationSoundAnticipation.Listener nextListener) { + listener = nextListener == null ? NO_OP : nextListener; + } + + public static int lookaheadMillis() { + return Math.max(0, Math.min(1000, listener.lookaheadMillis())); + } + + public static void anticipate(NonAnimationSoundEvents.SoundContext context) { + if (context != null) { + listener.anticipate(context); + } + } + + public static boolean allowNextStageLookahead(Identifier currentStageId, int stageIndex) { + return listener.allowNextStageLookahead(currentStageId, stageIndex); + } + + public interface Listener { + int lookaheadMillis(); + + void anticipate(NonAnimationSoundEvents.SoundContext var1); + + default boolean allowNextStageLookahead(Identifier currentStageId, int stageIndex) { + return true; + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundEffects.java b/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundEffects.java new file mode 100644 index 0000000..bbdff22 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/client/sound/AnimationSoundEffects.java @@ -0,0 +1,145 @@ +package com.nonid.internal.animation.client.sound; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.AnimationEngineClient; +import com.nonid.internal.animation.data.AnimationSoundCueParser; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; + +public final class AnimationSoundEffects { + private static volatile Map> SOUND_BY_ANIMATION = Map.of(); + private static final Identifier RELOADER_ID = AnimationEngine.runtimeId("sound_effects"); + + private AnimationSoundEffects() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.CLIENT_RESOURCES).registerReloader(RELOADER_ID, new AnimationSoundEffects.Reloader()); + } + + public static boolean hasAny() { + return !SOUND_BY_ANIMATION.isEmpty(); + } + + public static AnimationSoundEffects.SoundAnimation getSoundAnimation(Identifier animationResource, String animationKey) { + if (animationResource != null && animationKey != null && !animationKey.isBlank()) { + Map byKey = SOUND_BY_ANIMATION.get(animationResource); + return byKey == null ? null : byKey.get(animationKey); + } else { + return null; + } + } + + private static void reload(ResourceManager manager) { + Map resources = manager.findResources("geckolib/animations", id -> id.getPath().endsWith(".animation.json")); + List> entries = new ArrayList<>(resources.entrySet()); + entries.sort(Comparator.comparing(ex -> ((Identifier)ex.getKey()).toString())); + Map> loaded = new LinkedHashMap<>(); + int cueCount = 0; + + for (Entry entry : entries) { + Identifier fileId = entry.getKey(); + Identifier animationId = animationIdFromPath(fileId); + if (animationId != null) { + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + JsonObject animationsObj = root.has("animations") && root.get("animations").isJsonObject() ? root.getAsJsonObject("animations") : null; + if (animationsObj != null) { + for (Entry animEntry : animationsObj.entrySet()) { + String animKey = animEntry.getKey(); + JsonElement animEl = animEntry.getValue(); + if (animKey != null && !animKey.isBlank() && animEl != null && animEl.isJsonObject()) { + JsonObject animObj = animEl.getAsJsonObject(); + AnimationSoundCueParser.ParsedAnimation parsed = AnimationSoundCueParser.parse( + animObj, fileId.toString(), AnimationSoundEffects::setupWarn + ); + List cues = parsed.cues() + .stream() + .map( + cue -> new AnimationSoundEffects.SoundCue( + cue.timeSeconds(), cue.timeTicks(), cue.effect(), cue.randomSounds(), cue.volume(), cue.pitch() + ) + ) + .collect(Collectors.toCollection(ArrayList::new)); + if (!cues.isEmpty()) { + cues.sort(Comparator.comparingDouble(AnimationSoundEffects.SoundCue::timeTicks)); + AnimationSoundEffects.SoundAnimation soundAnim = new AnimationSoundEffects.SoundAnimation(parsed.lengthSeconds(), List.copyOf(cues)); + loaded.computeIfAbsent(animationId, id -> new LinkedHashMap<>()).put(animKey, soundAnim); + cueCount += cues.size(); + } + } + } + } + } catch (IOException | RuntimeException e) { + setupError("[NoN Animation] Failed to load sound cues {}", fileId, e); + } + } + } + + Map> finalized = new LinkedHashMap<>(); + + for (Entry> entry : loaded.entrySet()) { + finalized.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + + SOUND_BY_ANIMATION = Map.copyOf(finalized); + AnimationEngine.LOGGER.info("[NoN Animation] Loaded {} sound cue animation(s), {} cue(s)", SOUND_BY_ANIMATION.size(), cueCount); + } + + private static Identifier animationIdFromPath(Identifier fileId) { + String path = fileId.getPath(); + String prefix = "geckolib/animations/"; + String suffix = ".animation.json"; + if (path.startsWith(prefix) && path.endsWith(suffix)) { + String animPath = path.substring(prefix.length(), path.length() - suffix.length()); + return Identifier.of(fileId.getNamespace(), animPath); + } else { + return null; + } + } + + private static void setupWarn(String template, Object... args) { + AnimationEngine.LOGGER.warn(template, args); + AnimationEngineClient.sendClientSetupWarning(AnimationEngine.formatLogTemplate(template, args)); + } + + private static void setupError(String template, Object... args) { + AnimationEngine.LOGGER.error(template, args); + AnimationEngineClient.sendClientSetupWarning(AnimationEngine.formatLogTemplate(template, args)); + } + + public static final class Reloader implements SynchronousResourceReloader { + public void reload(ResourceManager manager) { + AnimationSoundEffects.reload(manager); + } + } + + public record SoundAnimation(double lengthSeconds, List cues) { + public double lengthTicks() { + return this.lengthSeconds <= 0.0 ? 0.0 : this.lengthSeconds * 20.0; + } + } + + public record SoundCue(double timeSeconds, double timeTicks, String effect, List randomSounds, float volume, float pitch) { + public SoundCue { + randomSounds = randomSounds == null ? List.of() : List.copyOf(randomSounds); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/data/AnimationCueEffects.java b/src/main/java/com/nonid/internal/animation/data/AnimationCueEffects.java new file mode 100644 index 0000000..6775f89 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/data/AnimationCueEffects.java @@ -0,0 +1,32 @@ +package com.nonid.internal.animation.data; + +import java.util.Locale; +import org.jetbrains.annotations.Nullable; + +public final class AnimationCueEffects { + public static final String REACTIVE_IMPACT = "reactiveimpact"; + public static final String REACTIVE_IMPACT_SILENT = "reactiveimpact_silent"; + + private AnimationCueEffects() { + } + + public static boolean isReactiveImpact(@Nullable String effect) { + String normalized = normalize(effect); + return "reactiveimpact".equals(normalized) || "reactiveimpact_silent".equals(normalized); + } + + public static boolean isSilentReactiveImpact(@Nullable String effect) { + return "reactiveimpact_silent".equals(normalize(effect)); + } + + @Nullable + public static String timingFamily(@Nullable String effect) { + String normalized = normalize(effect); + return "reactiveimpact_silent".equals(normalized) ? "reactiveimpact" : normalized; + } + + @Nullable + private static String normalize(@Nullable String effect) { + return effect != null && !effect.isBlank() ? effect.trim().toLowerCase(Locale.ROOT) : null; + } +} diff --git a/src/main/java/com/nonid/internal/animation/data/AnimationCueIndex.java b/src/main/java/com/nonid/internal/animation/data/AnimationCueIndex.java new file mode 100644 index 0000000..2dc1218 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/data/AnimationCueIndex.java @@ -0,0 +1,434 @@ +package com.nonid.internal.animation.data; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.util.AnimationIds; +import com.nonid.pack.NonExternalPackCatalog; +import com.nonid.pack.NonExternalPackProvider; +import com.nonid.pack.NonPackRootResolver; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.Map.Entry; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationCueIndex { + public static final String BIRTH_EFFECT = "birth"; + public static final String REACTIVE_IMPACT_EFFECT = "reactiveimpact"; + private static final String ANIMATION_SUFFIX = ".animation.json"; + private static final String ANIMATION_PATH_PREFIX = "geckolib/animations/afw/"; + private static volatile Map PROFILE_BY_CLIP = Map.of(); + + private AnimationCueIndex() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.SERVER_DATA).registerReloader(AnimationCueIndex.Reloader.ID, new AnimationCueIndex.Reloader()); + } + + @Nullable + public static AnimationCueIndex.CueProfile getProfile(@Nullable Identifier clipId) { + return clipId == null ? null : PROFILE_BY_CLIP.get(clipId); + } + + public static List getCueTicks(@Nullable Identifier clipId, @Nullable String effect) { + AnimationCueIndex.CueProfile profile = getProfile(clipId); + return profile == null ? List.of() : profile.cueTicks(effect); + } + + @Nullable + public static Integer getFirstCueTick(@Nullable Identifier clipId, @Nullable String effect) { + AnimationCueIndex.CueProfile profile = getProfile(clipId); + return profile == null ? null : profile.firstCueTick(effect); + } + + private static void reload() { + Map> selectedFiles = new LinkedHashMap<>(); + scanInstalledMods(selectedFiles); + scanExternalPacks(selectedFiles); + Map merged = new LinkedHashMap<>(); + + for (List clips : selectedFiles.values()) { + for (AnimationCueIndex.ParsedClip clip : clips) { + if (clip != null && clip.clipId() != null) { + AnimationCueIndex.MutableProfile profile = merged.computeIfAbsent(clip.clipId(), ignored -> new AnimationCueIndex.MutableProfile()); + + for (Entry> cues : clip.cueTicksByEffect().entrySet()) { + profile.cueTicksByEffect.computeIfAbsent(cues.getKey(), ignored -> new TreeSet<>()).addAll(cues.getValue()); + } + + Integer cycleTicks = clip.cycleTicks(); + if (cycleTicks != null && cycleTicks > 0 && (profile.cycleTicks == null || cycleTicks > profile.cycleTicks)) { + profile.cycleTicks = cycleTicks; + } + } + } + } + + Map finalized = new LinkedHashMap<>(); + + for (Entry entry : merged.entrySet()) { + Map> cuesByEffect = new LinkedHashMap<>(); + + for (Entry> cues : entry.getValue().cueTicksByEffect.entrySet()) { + if (!cues.getValue().isEmpty()) { + cuesByEffect.put(cues.getKey(), List.copyOf(cues.getValue())); + } + } + + Integer cycleTicks = entry.getValue().cycleTicks; + if (!cuesByEffect.isEmpty() || cycleTicks != null) { + finalized.put(entry.getKey(), new AnimationCueIndex.CueProfile(Map.copyOf(cuesByEffect), cycleTicks)); + } + } + + PROFILE_BY_CLIP = Map.copyOf(finalized); + } + + private static void scanInstalledMods(Map> selectedFiles) { + for (ModContainer mod : FabricLoader.getInstance().getAllMods()) { + for (Path root : mod.getRootPaths()) { + Path assetsRoot = root.resolve("assets"); + if (Files.isDirectory(assetsRoot)) { + loadAssetNamespaces(assetsRoot, selectedFiles); + } + } + } + } + + private static void scanExternalPacks(Map> selectedFiles) { + Path packsDir = NonExternalPackProvider.resolveDefaultPacksDir(); + if (Files.isDirectory(packsDir)) { + NonConfig config = NeedsOfNature.getConfig(); + List configuredOrder = config == null ? List.of() : config.getExternalNoNPackLoadOrder(); + + for (NonExternalPackCatalog.Entry entry : NonExternalPackCatalog.resolveLowToHigh(packsDir, configuredOrder)) { + scanExternalPack(entry.root(), selectedFiles); + } + } + } + + private static void scanExternalPack(NonPackRootResolver.ResolvedRoot root, Map> selectedFiles) { + if (!root.isZip()) { + Path assetsRoot = root.rootPath().resolve("assets"); + if (Files.isDirectory(assetsRoot)) { + loadAssetNamespaces(assetsRoot, selectedFiles); + } + } else { + try (ZipFile zip = new ZipFile(root.sourcePath().toFile())) { + Enumeration entries = zip.entries(); + + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry != null && !entry.isDirectory()) { + String entryPath = root.stripZipPrefix(entry.getName()); + if (entryPath != null && !entryPath.isBlank()) { + AnimationCueIndex.ParsedPath parsedPath = parseZipAssetPath(entryPath.replace('\\', '/')); + if (parsedPath != null && isAnimationAssetPath(parsedPath.namespaceRelativePath())) { + try (InputStream input = zip.getInputStream(entry)) { + selectedFiles.put( + parsedPath.resourceKey(), + parseAnimationFile(input, parsedPath.namespace(), parsedPath.namespaceRelativePath(), root.sourcePath() + "!" + entryPath) + ); + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to parse animation cues in {} entry {}", new Object[]{root.sourcePath(), entryPath, e}); + selectedFiles.put(parsedPath.resourceKey(), List.of()); + } + } + } + } + } + } catch (IOException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to scan external NoN pack for animation cues {}", root.sourcePath(), e); + } + } + } + + private static void loadAssetNamespaces(Path assetsRoot, Map> selectedFiles) { + try (Stream namespaces = Files.list(assetsRoot)) { + namespaces.filter(x$0 -> Files.isDirectory(x$0)).forEach(namespaceDir -> loadNamespaceFiles(namespaceDir, selectedFiles)); + } catch (IOException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to enumerate namespaces under {}", assetsRoot, e); + } + } + + private static void loadNamespaceFiles(Path namespaceDir, Map> selectedFiles) { + String namespace = namespaceDir.getFileName().toString(); + Path animationRoot = namespaceDir.resolve("geckolib").resolve("animations").resolve("afw"); + if (Files.isDirectory(animationRoot)) { + try (Stream files = Files.walk(animationRoot)) { + files.filter(x$0 -> Files.isRegularFile(x$0)).filter(path -> path.getFileName().toString().endsWith(".animation.json")).forEach(path -> { + String relativePath = namespaceDir.relativize(path).toString().replace('\\', '/'); + String resourceKey = namespace + ":" + relativePath; + + try (InputStream input = Files.newInputStream(path)) { + selectedFiles.put(resourceKey, parseAnimationFile(input, namespace, relativePath, path.toString())); + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to parse animation cue file {}", path, e); + selectedFiles.put(resourceKey, List.of()); + } + }); + } catch (IOException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to walk animation assets under {}", animationRoot, e); + } + } + } + + private static List parseAnimationFile(InputStream input, String namespace, String namespaceRelativePath, String sourceName) { + if (input != null && namespace != null && !namespace.isBlank() && isAnimationAssetPath(namespaceRelativePath)) { + String normalizedPath = namespaceRelativePath.replace('\\', '/'); + String relativeAnimationPath = normalizedPath.substring("geckolib/animations/afw/".length()); + Identifier pathClipId = stageClipIdFromAnimationPath(namespace, relativeAnimationPath); + Identifier conjoinedAnimationId = conjoinedAnimationIdFromPath(namespace, relativeAnimationPath); + + try (InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + JsonObject animations = asObject(root.get("animations")); + if (animations == null || animations.isEmpty()) { + return List.of(); + } + + Map parsedByClip = new LinkedHashMap<>(); + + for (Entry animationEntry : animations.entrySet()) { + JsonObject animation = asObject(animationEntry.getValue()); + if (animation != null) { + Identifier clipId = pathClipId != null ? pathClipId : stageClipId(namespace, animationEntry.getKey()); + if (clipId == null) { + clipId = conjoinedStageClipId(conjoinedAnimationId, animationEntry.getKey()); + } + + if (clipId != null) { + AnimationCueIndex.MutableProfile profile = parsedByClip.computeIfAbsent(clipId, ignored -> new AnimationCueIndex.MutableProfile()); + AnimationSoundCueParser.ParsedAnimation parsedAnimation = AnimationSoundCueParser.parse( + animation, sourceName, NeedsOfNature::logSetupWarning + ); + Integer cycleTicks = parsedAnimation.lengthSeconds() > 0.0 ? Math.max(1, (int)Math.round(parsedAnimation.lengthSeconds() * 20.0)) : null; + if (cycleTicks != null && cycleTicks > 0 && (profile.cycleTicks == null || cycleTicks > profile.cycleTicks)) { + profile.cycleTicks = cycleTicks; + } + + for (AnimationSoundCueParser.ParsedCue cue : parsedAnimation.cues()) { + if (cue.effect() != null && !cue.effect().isBlank()) { + String effect = AnimationCueEffects.timingFamily(cue.effect()); + if (effect != null) { + int cueTick = Math.max(0, (int)Math.round(cue.timeTicks())); + profile.cueTicksByEffect.computeIfAbsent(effect, ignored -> new TreeSet<>()).add(cueTick); + } + } + } + } + } + } + + List parsed = new ArrayList<>(); + + for (Entry entry : parsedByClip.entrySet()) { + Map> cuesByEffect = new LinkedHashMap<>(); + + for (Entry> cues : entry.getValue().cueTicksByEffect.entrySet()) { + if (!cues.getValue().isEmpty()) { + cuesByEffect.put(cues.getKey(), List.copyOf(cues.getValue())); + } + } + + parsed.add(new AnimationCueIndex.ParsedClip(entry.getKey(), Map.copyOf(cuesByEffect), entry.getValue().cycleTicks)); + } + + return List.copyOf(parsed); + } catch (IOException | RuntimeException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to parse animation cues in {}", sourceName, e); + return List.of(); + } + } else { + return List.of(); + } + } + + private static boolean isAnimationAssetPath(@Nullable String namespaceRelativePath) { + if (namespaceRelativePath != null && !namespaceRelativePath.isBlank()) { + String normalized = namespaceRelativePath.replace('\\', '/'); + return normalized.startsWith("geckolib/animations/afw/") && normalized.endsWith(".animation.json"); + } else { + return false; + } + } + + @Nullable + private static Identifier stageClipIdFromAnimationPath(String namespace, String relativeAnimationPath) { + if (namespace != null && !namespace.isBlank() && relativeAnimationPath != null && !relativeAnimationPath.isBlank()) { + String normalized = relativeAnimationPath.replace('\\', '/'); + int slash = normalized.lastIndexOf(47); + String fileName = slash >= 0 ? normalized.substring(slash + 1) : normalized; + return !fileName.endsWith(".animation.json") ? null : stageClipId(namespace, fileName.substring(0, fileName.length() - ".animation.json".length())); + } else { + return null; + } + } + + @Nullable + private static Identifier conjoinedAnimationIdFromPath(String namespace, String relativeAnimationPath) { + if (namespace != null && !namespace.isBlank() && relativeAnimationPath != null && !relativeAnimationPath.isBlank()) { + String normalized = relativeAnimationPath.replace('\\', '/'); + if (!normalized.endsWith(".animation.json")) { + return null; + } + + String animationPath = normalized.substring(0, normalized.length() - ".animation.json".length()); + if (animationPath.isBlank()) { + return null; + } + + int slash = animationPath.lastIndexOf(47); + if (slash > 0) { + String fileName = animationPath.substring(slash + 1); + String parentPath = animationPath.substring(0, slash); + int parentSlash = parentPath.lastIndexOf(47); + String parentName = parentSlash >= 0 ? parentPath.substring(parentSlash + 1) : parentPath; + if (fileName.equals(parentName)) { + animationPath = parentPath; + } + } + + return Identifier.tryParse(namespace + ":" + animationPath); + } else { + return null; + } + } + + @Nullable + private static Identifier stageClipId(String namespace, String rawClipPath) { + if (namespace != null && !namespace.isBlank() && rawClipPath != null && !rawClipPath.isBlank()) { + int stageMarker = rawClipPath.lastIndexOf(".p"); + if (stageMarker <= 0) { + return null; + } + + int end = stageMarker + 2; + + while (end < rawClipPath.length() && Character.isDigit(rawClipPath.charAt(end))) { + end++; + } + + if (end == stageMarker + 2) { + return null; + } else { + return end < rawClipPath.length() && rawClipPath.charAt(end) != '_' ? null : Identifier.tryParse(namespace + ":" + rawClipPath.substring(0, end)); + } + } else { + return null; + } + } + + @Nullable + private static Identifier conjoinedStageClipId(@Nullable Identifier animationId, String animationKey) { + if (animationId != null && animationKey != null && animationKey.length() >= 4 && animationKey.charAt(0) == 'p') { + int separator = 1; + + while (separator < animationKey.length() && Character.isDigit(animationKey.charAt(separator))) { + separator++; + } + + if (separator != 1 && separator < animationKey.length() - 1 && animationKey.charAt(separator) == '_') { + int stageNumber; + try { + stageNumber = Integer.parseInt(animationKey.substring(1, separator)); + } catch (NumberFormatException e) { + return null; + } + + return stageNumber <= 0 ? null : AnimationIds.stageId(animationId, stageNumber); + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static JsonObject asObject(@Nullable JsonElement element) { + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } + + @Nullable + private static AnimationCueIndex.ParsedPath parseZipAssetPath(String entryPath) { + if (entryPath != null && entryPath.startsWith("assets/")) { + int namespaceStart = "assets/".length(); + int namespaceEnd = entryPath.indexOf(47, namespaceStart); + if (namespaceEnd <= namespaceStart) { + return null; + } + + String namespace = entryPath.substring(namespaceStart, namespaceEnd); + String namespaceRelativePath = entryPath.substring(namespaceEnd + 1); + return !namespace.isBlank() && !namespaceRelativePath.isBlank() ? new AnimationCueIndex.ParsedPath(namespace, namespaceRelativePath) : null; + } else { + return null; + } + } + + public record CueProfile(Map> cueTicksByEffect, @Nullable Integer cycleTicks) { + public List cueTicks(@Nullable String effect) { + String normalized = normalizeEffect(effect); + return normalized == null ? List.of() : this.cueTicksByEffect.getOrDefault(normalized, List.of()); + } + + @Nullable + public Integer firstCueTick(@Nullable String effect) { + List ticks = this.cueTicks(effect); + return ticks.isEmpty() ? null : ticks.getFirst(); + } + + @Nullable + private static String normalizeEffect(@Nullable String effect) { + return AnimationCueEffects.timingFamily(effect); + } + } + + private static final class MutableProfile { + private final Map> cueTicksByEffect = new LinkedHashMap<>(); + @Nullable + private Integer cycleTicks; + } + + private record ParsedClip(Identifier clipId, Map> cueTicksByEffect, @Nullable Integer cycleTicks) { + } + + private record ParsedPath(String namespace, String namespaceRelativePath) { + private String resourceKey() { + return this.namespace + ":" + this.namespaceRelativePath; + } + } + + private static final class Reloader implements SynchronousResourceReloader { + private static final Identifier ID = AnimationEngine.runtimeId("cue_index"); + + public void reload(ResourceManager manager) { + AnimationCueIndex.reload(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/data/AnimationDefinitions.java b/src/main/java/com/nonid/internal/animation/data/AnimationDefinitions.java new file mode 100644 index 0000000..4a3408f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/data/AnimationDefinitions.java @@ -0,0 +1,2931 @@ +package com.nonid.internal.animation.data; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.nonid.api.animation.NonActorEvents; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.AnimationIds; +import com.nonid.internal.animation.util.EntityVariants; +import com.nonid.internal.animation.util.StageTimeWarp; +import com.nonid.internal.animation.util.WeightedPicker; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.TreeMap; +import java.util.Map.Entry; +import java.util.stream.Stream; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.registry.Registries; +import net.minecraft.resource.InputSupplier; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourcePack; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.SynchronousResourceReloader; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Direction.Axis; +import org.jetbrains.annotations.Nullable; + +public final class AnimationDefinitions { + private static final int MAX_MODEL_PROVIDER_ROOTS = 32; + private static final String NORMAL_MATCH_TAG = "normal_match"; + private static final Identifier PLAYER_ID = Identifier.of("minecraft", "player"); + private static final Identifier PLAYER_SLIM_ID = Identifier.of("minecraft", "player_slim"); + private static final String INVALID_ENTITY_VARIANT = "\u0000"; + private static final Set KNOWN_DEFINITION_KEYS = Set.of( + "actors", + "display_name", + "animation_tags", + "content_tags", + "join_after", + "required_union_tags", + "weight", + "attack_eligible", + "speed", + "block_requirements", + "water", + "position_anchor_actor", + "stages", + "manual_peak", + "liquid_gain_multiplier", + "stage_seconds", + "escapable" + ); + private static final Set KNOWN_ACTOR_KEYS = Set.of( + "label", + "entity_types", + "entity_variant", + "actor_tags", + "actor_tags_any", + "age", + "activity", + "prop_left", + "prop_right", + "prop_floor", + "injector", + "receiver" + ); + private static final Set KNOWN_STAGE_KEYS = Set.of( + "stage", + "use_stage", + "loop", + "cycle_seconds", + "speed", + "allow_join", + "cycle_midpoint_offset_seconds", + "props", + "stage_seconds", + "stage_duration_multiplier", + "escapable", + "non_peak" + ); + private static final Set KNOWN_BLOCK_REQUIREMENT_KEYS = Set.of( + "type", "height", "width", "facing", "clearance", "support", "placement", "surface_radius", "surface_footprint", "blocks" + ); + private static final Set KNOWN_WALL_HEIGHT_KEYS = Set.of("min", "max"); + private static final Set KNOWN_WALL_WIDTH_KEYS = Set.of("min", "max"); + private static final Set KNOWN_CLEARANCE_KEYS = Set.of("width", "height", "depth"); + private static final Set KNOWN_SURFACE_FOOTPRINT_KEYS = Set.of("width", "height", "depth", "margin"); + private static final Set KNOWN_STAGE_ACTOR_PROP_KEYS = Set.of("prop_left", "prop_right", "prop_floor"); + private static volatile List DEFINITIONS = List.of(); + private static volatile Map DEFINITIONS_BY_ID = Map.of(); + private static volatile Map> DEFINITIONS_BY_ACTOR_COUNT = Map.of(); + private static volatile Map> MATCH_ORDER_BY_DEFINITION = Map.of(); + private static volatile Map> JOIN_FOLLOWERS_BY_PREDECESSOR = Map.of(); + private static volatile Set GENERIC_ACTOR_TYPES = Set.of(); + private static volatile Set GENERIC_MOB_PAIR_TYPES = Set.of(); + private static volatile Map> GENERIC_MOB_PAIR_COMPATIBILITY = Map.of(); + private static volatile Set DISABLED_ANIMATION_IDS = Set.of(); + private static volatile Set DISABLED_ANIMATION_PACK_IDS = Set.of(); + + private AnimationDefinitions() { + } + + public static void registerReloadListener() { + ResourceLoader.get(ResourceType.SERVER_DATA).registerReloader(AnimationDefinitions.Reloader.RELOADER_ID, new AnimationDefinitions.Reloader()); + } + + public static AnimationDefinitions.MatchResult match(List selectedActorsSortedById) { + return match(selectedActorsSortedById, Set.of()); + } + + public static AnimationDefinitions.MatchResult match(List selectedActorsSortedById, Set requiredAnimationTags) { + List candidates = findMatchingCandidates(selectedActorsSortedById, requiredAnimationTags); + AnimationDefinitions.Definition chosen = pickWeighted(candidates, new Random()); + return new AnimationDefinitions.MatchResult(candidates, chosen); + } + + public static List findMatchingCandidates(List selectedActorsSortedById, Set requiredAnimationTags) { + List actorMatches = findActorMatchingCandidates(selectedActorsSortedById); + if (actorMatches.isEmpty()) { + return List.of(); + } + + Set normalizedRequiredTags = normalizeTags(requiredAnimationTags); + List candidates = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : actorMatches) { + if (definition.matchesAnimationTags(normalizedRequiredTags)) { + candidates.add(definition); + } + } + + return candidates.isEmpty() ? List.of() : List.copyOf(candidates); + } + + public static List findActorMatchingCandidates(List selectedActorsSortedById) { + if (selectedActorsSortedById != null && !selectedActorsSortedById.isEmpty()) { + AnimationDefinitions.MatchContext context = AnimationDefinitions.MatchContext.create(selectedActorsSortedById); + if (context.actors().size() != selectedActorsSortedById.size()) { + return List.of(); + } + + List candidates = new ArrayList<>(); + + for (AnimationDefinitions.Definition d : DEFINITIONS_BY_ACTOR_COUNT.getOrDefault(context.actors().size(), List.of())) { + if (isDefinitionEnabled(d) && d.matchesActors(context)) { + candidates.add(d); + } + } + + return candidates.isEmpty() ? List.of() : List.copyOf(candidates); + } else { + return List.of(); + } + } + + private static AnimationDefinitions.Definition pickWeighted(List definitions, Random random) { + return random == null ? null : WeightedPicker.pick(definitions, AnimationDefinitions.Definition::weight, random.nextDouble()); + } + + public static AnimationDefinitions.Definition getDefinition(Identifier id) { + return id == null ? null : DEFINITIONS_BY_ID.get(id); + } + + public static boolean isDefinitionEnabled(@Nullable AnimationDefinitions.Definition definition) { + if (definition == null) { + return false; + } + + if (DISABLED_ANIMATION_IDS.contains(definition.id())) { + return false; + } + + AnimationDefinitions.AnimationPackInfo packInfo = definition.packInfo(); + return packInfo == null || !DISABLED_ANIMATION_PACK_IDS.contains(packInfo.id()); + } + + public static boolean isAnimationEnabled(Identifier animationId) { + AnimationDefinitions.Definition definition = getDefinition(animationId); + return definition != null && isDefinitionEnabled(definition); + } + + public static boolean isJoinCandidateAllowed(@Nullable Identifier predecessorAnimationId, @Nullable AnimationDefinitions.Definition candidate) { + if (candidate == null || !isDefinitionEnabled(candidate)) { + return false; + } + + if (predecessorAnimationId == null) { + return true; + } + + boolean hasEnabledFollower = false; + + for (AnimationDefinitions.Definition follower : JOIN_FOLLOWERS_BY_PREDECESSOR.getOrDefault(predecessorAnimationId, List.of())) { + if (isDefinitionEnabled(follower)) { + hasEnabledFollower = true; + if (follower.id().equals(candidate.id())) { + return true; + } + } + } + + return !hasEnabledFollower && candidate.joinAfter().isEmpty(); + } + + public static boolean hasEnabledGenericAnimationForEntityType(@Nullable Identifier entityTypeId, boolean baby) { + return entityTypeId != null && GENERIC_ACTOR_TYPES.contains(new AnimationDefinitions.ActorKey(entityTypeId, baby)); + } + + public static boolean hasEnabledGenericMobPairAnimationForEntityType(@Nullable Identifier entityTypeId, boolean baby) { + return entityTypeId != null && GENERIC_MOB_PAIR_TYPES.contains(new AnimationDefinitions.ActorKey(entityTypeId, baby)); + } + + public static boolean areEnabledGenericMobPairTypesCompatible( + @Nullable Identifier firstType, boolean firstBaby, @Nullable Identifier secondType, boolean secondBaby + ) { + if (firstType != null && secondType != null) { + Set partners = GENERIC_MOB_PAIR_COMPATIBILITY.get(new AnimationDefinitions.ActorKey(firstType, firstBaby)); + return partners != null && partners.contains(new AnimationDefinitions.ActorKey(secondType, secondBaby)); + } else { + return false; + } + } + + private static boolean isGenericMatchDefinition(AnimationDefinitions.Definition definition) { + if (definition == null) { + return false; + } + + Set tags = definition.animationTags(); + return tags == null || tags.isEmpty() || tags.contains("normal_match"); + } + + public static void setDisabledAnimationFilters(@Nullable Collection animationIds, @Nullable Collection packIds) { + LinkedHashSet cleanedAnimations = new LinkedHashSet<>(); + if (animationIds != null) { + for (Identifier id : animationIds) { + if (id != null) { + cleanedAnimations.add(id); + } + } + } + + LinkedHashSet cleanedPacks = new LinkedHashSet<>(); + if (packIds != null) { + for (String raw : packIds) { + String id = sanitizeNullable(raw); + if (id != null) { + cleanedPacks.add(id); + } + } + } + + DISABLED_ANIMATION_IDS = Set.copyOf(cleanedAnimations); + DISABLED_ANIMATION_PACK_IDS = Set.copyOf(cleanedPacks); + rebuildEligibilityIndexes(); + } + + public static List getLoadedDefinitionsSnapshot() { + return List.copyOf(DEFINITIONS); + } + + public static List resolveActorKeys(AnimationDefinitions.Definition definition) { + return definition != null && !definition.actors().isEmpty() ? List.of(buildUniqueActorKeys(definition.actors())) : List.of(); + } + + @Nullable + public static AnimationDefinitions.ActorConstraint resolveActorConstraintForKey(AnimationDefinitions.Definition definition, String actorKey) { + if (definition != null && actorKey != null && !actorKey.isBlank()) { + String[] actorKeys = buildUniqueActorKeys(definition.actors()); + + for (int i = 0; i < actorKeys.length; i++) { + if (actorKey.equals(actorKeys[i])) { + return definition.actors().get(i); + } + } + + return null; + } else { + return null; + } + } + + public static List resolveActorKeys(AnimationDefinitions.Definition definition, List selectedActorsSortedById) { + return resolveActorKeys(definition, selectedActorsSortedById, net.minecraft.util.math.random.Random.create()); + } + + @Nullable + public static AnimationDefinitions.ActorConstraint resolveActorConstraintForEntity( + AnimationDefinitions.Definition definition, List selectedActorsSortedById, Entity actor + ) { + return resolveActorConstraintForEntity(definition, selectedActorsSortedById, actor, net.minecraft.util.math.random.Random.create()); + } + + @Nullable + public static AnimationDefinitions.ActorConstraint resolveActorConstraintForEntity( + AnimationDefinitions.Definition definition, List selectedActorsSortedById, Entity actor, net.minecraft.util.math.random.Random rng + ) { + if (definition != null && selectedActorsSortedById != null && actor != null) { + if (selectedActorsSortedById.size() != definition.actors().size()) { + return null; + } + + int actorIndex = -1; + + for (int i = 0; i < selectedActorsSortedById.size(); i++) { + Entity candidate = selectedActorsSortedById.get(i); + if (candidate == actor || candidate.getUuid().equals(actor.getUuid())) { + actorIndex = i; + break; + } + } + + if (actorIndex < 0) { + return null; + } + + int[] assignment = matchUnorderedWithAssignment(selectedActorsSortedById, definition.actors(), rng); + if (assignment == null) { + return null; + } + + for (int ci = 0; ci < assignment.length; ci++) { + if (assignment[ci] == actorIndex) { + return definition.actors().get(ci); + } + } + + return null; + } else { + return null; + } + } + + public static boolean canResolveActorAsActivity( + AnimationDefinitions.Definition definition, List selectedActorsSortedById, Entity actor, AnimationDefinitions.ActorActivity activity + ) { + if (definition != null && selectedActorsSortedById != null && actor != null && activity != null) { + if (selectedActorsSortedById.size() != definition.actors().size()) { + return false; + } + + int actorIndex = -1; + + for (int i = 0; i < selectedActorsSortedById.size(); i++) { + Entity candidate = selectedActorsSortedById.get(i); + if (candidate == actor || candidate.getUuid().equals(actor.getUuid())) { + actorIndex = i; + break; + } + } + + if (actorIndex < 0) { + return false; + } + + List constraints = definition.actors(); + Entity actorEntity = selectedActorsSortedById.get(actorIndex); + + for (int ci = 0; ci < constraints.size(); ci++) { + AnimationDefinitions.ActorConstraint forced = constraints.get(ci); + AnimationDefinitions.ActorActivity forcedActivity = forced.activity() == null ? AnimationDefinitions.ActorActivity.ACTIVE : forced.activity(); + if (forcedActivity == activity && forced.matches(actorEntity)) { + boolean[] used = new boolean[selectedActorsSortedById.size()]; + used[actorIndex] = true; + List order = new ArrayList<>(constraints.size() - 1); + + for (int i = 0; i < constraints.size(); i++) { + if (i != ci) { + order.add(i); + } + } + + order.sort((a, b) -> { + AnimationDefinitions.ActorConstraint ca = constraints.get(a); + AnimationDefinitions.ActorConstraint cb = constraints.get(b); + int cmp = Integer.compare(cb.assignmentPriority(), ca.assignmentPriority()); + return cmp != 0 ? cmp : ca.stableKey().compareTo(cb.stableKey()); + }); + if (backtrackAssign(0, order, constraints, selectedActorsSortedById, buildEntityOrder(selectedActorsSortedById.size(), null), used, null)) { + return true; + } + } + } + + return false; + } else { + return false; + } + } + + public static List resolveActorKeys( + AnimationDefinitions.Definition definition, List selectedActorsSortedById, net.minecraft.util.math.random.Random rng + ) { + if (definition == null) { + return List.of(); + } + + if (selectedActorsSortedById.size() != definition.actors().size()) { + return fallbackActorKeys(selectedActorsSortedById); + } + + int[] assignment = matchUnorderedWithAssignment(selectedActorsSortedById, definition.actors(), rng); + if (assignment == null) { + return fallbackActorKeys(selectedActorsSortedById); + } + + String[] constraintKeys = buildUniqueActorKeys(definition.actors()); + String[] keysByEntityIndex = new String[selectedActorsSortedById.size()]; + + for (int ci = 0; ci < assignment.length; ci++) { + int entityIndex = assignment[ci]; + if (entityIndex >= 0 && entityIndex < keysByEntityIndex.length) { + keysByEntityIndex[entityIndex] = constraintKeys[ci]; + } + } + + for (int i = 0; i < keysByEntityIndex.length; i++) { + if (keysByEntityIndex[i] == null) { + keysByEntityIndex[i] = fallbackActorKey(selectedActorsSortedById.get(i)); + } + } + + return List.of(keysByEntityIndex); + } + + public static List> resolveAllActorKeyAssignments(AnimationDefinitions.Definition definition, List selectedActorsSortedById) { + if (definition != null && selectedActorsSortedById != null) { + if (selectedActorsSortedById.size() != definition.actors().size()) { + return List.of(); + } + + List constraints = definition.actors(); + List constraintOrder = new ArrayList<>(constraints.size()); + + for (int i = 0; i < constraints.size(); i++) { + constraintOrder.add(i); + } + + constraintOrder.sort((a, b) -> { + AnimationDefinitions.ActorConstraint ca = constraints.get(a); + AnimationDefinitions.ActorConstraint cb = constraints.get(b); + int cmp = Integer.compare(cb.assignmentPriority(), ca.assignmentPriority()); + return cmp != 0 ? cmp : ca.stableKey().compareTo(cb.stableKey()); + }); + int[] assignment = new int[constraints.size()]; + Arrays.fill(assignment, -1); + List assignments = new ArrayList<>(); + collectAssignments(0, constraintOrder, constraints, selectedActorsSortedById, new boolean[selectedActorsSortedById.size()], assignment, assignments); + String[] constraintKeys = buildUniqueActorKeys(constraints); + LinkedHashSet> unique = new LinkedHashSet<>(); + + for (int[] resolved : assignments) { + String[] keysByEntityIndex = new String[selectedActorsSortedById.size()]; + + for (int constraintIndex = 0; constraintIndex < resolved.length; constraintIndex++) { + int entityIndex = resolved[constraintIndex]; + if (entityIndex >= 0 && entityIndex < keysByEntityIndex.length) { + keysByEntityIndex[entityIndex] = constraintKeys[constraintIndex]; + } + } + + if (Arrays.stream(keysByEntityIndex).noneMatch(Objects::isNull)) { + unique.add(List.of(keysByEntityIndex)); + } + } + + List> result = new ArrayList<>(unique); + result.sort(Comparator.comparing(keys -> String.join("\u0000", keys))); + return List.copyOf(result); + } else { + return List.of(); + } + } + + private static void collectAssignments( + int pos, + List constraintOrder, + List constraints, + List entitiesSortedById, + boolean[] used, + int[] assignment, + List output + ) { + if (pos >= constraintOrder.size()) { + output.add(Arrays.copyOf(assignment, assignment.length)); + } else { + int constraintIndex = constraintOrder.get(pos); + AnimationDefinitions.ActorConstraint constraint = constraints.get(constraintIndex); + + for (int entityIndex = 0; entityIndex < entitiesSortedById.size(); entityIndex++) { + if (!used[entityIndex] && constraint.matches(entitiesSortedById.get(entityIndex))) { + used[entityIndex] = true; + assignment[constraintIndex] = entityIndex; + collectAssignments(pos + 1, constraintOrder, constraints, entitiesSortedById, used, assignment, output); + assignment[constraintIndex] = -1; + used[entityIndex] = false; + } + } + } + } + + private static boolean matchesUnordered( + List actors, List constraints, @Nullable List cachedOrder + ) { + int n = actors.size(); + boolean[] used = new boolean[n]; + List order = cachedOrder == null ? buildConstraintOrder(constraints) : cachedOrder; + return backtrackMatchActors(0, order, constraints, actors, used); + } + + private static boolean backtrackMatchActors( + int pos, + List constraintOrder, + List constraints, + List actors, + boolean[] used + ) { + if (pos >= constraintOrder.size()) { + return true; + } + + AnimationDefinitions.ActorConstraint constraint = constraints.get(constraintOrder.get(pos)); + + for (int actorIndex = 0; actorIndex < actors.size(); actorIndex++) { + if (!used[actorIndex] && constraint.matches(actors.get(actorIndex))) { + used[actorIndex] = true; + if (backtrackMatchActors(pos + 1, constraintOrder, constraints, actors, used)) { + return true; + } + + used[actorIndex] = false; + } + } + + return false; + } + + private static List buildConstraintOrder(List constraints) { + List order = new ArrayList<>(constraints.size()); + + for (int i = 0; i < constraints.size(); i++) { + order.add(i); + } + + order.sort((a, b) -> { + AnimationDefinitions.ActorConstraint left = constraints.get(a); + AnimationDefinitions.ActorConstraint right = constraints.get(b); + int priority = Integer.compare(right.assignmentPriority(), left.assignmentPriority()); + return priority != 0 ? priority : left.stableKey().compareTo(right.stableKey()); + }); + return List.copyOf(order); + } + + private static int[] matchUnorderedWithAssignment( + List entitiesSortedById, List constraints, net.minecraft.util.math.random.Random rng + ) { + int n = entitiesSortedById.size(); + boolean[] used = new boolean[n]; + int[] assignment = new int[constraints.size()]; + Arrays.fill(assignment, -1); + int[] entityOrder = buildEntityOrder(n, rng); + List order = new ArrayList<>(constraints.size()); + + for (int i = 0; i < constraints.size(); i++) { + order.add(i); + } + + order.sort((a, b) -> { + AnimationDefinitions.ActorConstraint ca = constraints.get(a); + AnimationDefinitions.ActorConstraint cb = constraints.get(b); + int cmp = Integer.compare(cb.assignmentPriority(), ca.assignmentPriority()); + return cmp != 0 ? cmp : ca.stableKey().compareTo(cb.stableKey()); + }); + return backtrackAssign(0, order, constraints, entitiesSortedById, entityOrder, used, assignment) ? assignment : null; + } + + private static boolean backtrackAssign( + int pos, + List constraintOrder, + List constraints, + List entitiesSortedById, + int[] entityOrder, + boolean[] used, + int[] assignment + ) { + if (pos >= constraintOrder.size()) { + return true; + } + + int constraintIndex = constraintOrder.get(pos); + AnimationDefinitions.ActorConstraint c = constraints.get(constraintIndex); + + for (int ei : entityOrder) { + if (!used[ei]) { + Entity e = entitiesSortedById.get(ei); + if (c.matches(e)) { + used[ei] = true; + if (assignment != null) { + assignment[constraintIndex] = ei; + } + + if (backtrackAssign(pos + 1, constraintOrder, constraints, entitiesSortedById, entityOrder, used, assignment)) { + return true; + } + + used[ei] = false; + if (assignment != null) { + assignment[constraintIndex] = -1; + } + } + } + } + + return false; + } + + private static int[] buildEntityOrder(int size, net.minecraft.util.math.random.Random rng) { + int[] order = new int[size]; + int i = 0; + + while (i < size) { + order[i] = i++; + } + + if (rng != null && size > 1) { + for (int ix = size - 1; ix > 0; ix--) { + int j = rng.nextInt(ix + 1); + int tmp = order[ix]; + order[ix] = order[j]; + order[j] = tmp; + } + + return order; + } else { + return order; + } + } + + private static List fallbackActorKeys(List selectedActorsSortedById) { + if (selectedActorsSortedById.isEmpty()) { + return List.of(); + } + + String[] out = new String[selectedActorsSortedById.size()]; + + for (int i = 0; i < selectedActorsSortedById.size(); i++) { + out[i] = fallbackActorKey(selectedActorsSortedById.get(i)); + } + + return List.of(out); + } + + private static String fallbackActorKey(Entity entity) { + Identifier typeId = Registries.ENTITY_TYPE.getId(entity.getType()); + return typeId.getPath(); + } + + private static String[] buildUniqueActorKeys(List constraints) { + String[] out = new String[constraints.size()]; + Map counts = new HashMap<>(); + + for (int i = 0; i < constraints.size(); i++) { + String base = constraints.get(i).derivedKey(); + int count = counts.merge(base, 1, Integer::sum); + out[i] = count == 1 ? base : base + "_" + count; + } + + return out; + } + + private static String sanitizeKeyPart(String value) { + if (value != null && !value.isEmpty()) { + String lower = value.toLowerCase(Locale.ROOT); + StringBuilder sb = new StringBuilder(lower.length()); + + for (int i = 0; i < lower.length(); i++) { + char c = lower.charAt(i); + if ((c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_') { + sb.append('_'); + } else { + sb.append(c); + } + } + + return sb.toString(); + } else { + return "actor"; + } + } + + private static void reload(ResourceManager resourceManager) { + Map packInfoByResourcePackId = readAnimationPackInfos(resourceManager); + Map resources = resourceManager.findResources("afw_animdefs", id -> id.getPath().endsWith(".json")); + List loaded = new ArrayList<>(); + + for (Entry entry : resources.entrySet()) { + Identifier fileId = entry.getKey(); + String resourcePackId = entry.getValue().getPackId(); + AnimationDefinitions.AnimationPackInfo packInfo = packInfoByResourcePackId.getOrDefault(resourcePackId, fallbackPackInfo(resourcePackId)); + + try (InputStreamReader reader = new InputStreamReader(entry.getValue().getInputStream(), StandardCharsets.UTF_8)) { + JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject(); + warnUnknownKeys(obj, fileId, "animdef", KNOWN_DEFINITION_KEYS); + if (!obj.has("actors")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: missing 'actors' array", fileId); + } else { + JsonArray actorsArr = obj.getAsJsonArray("actors"); + if (actorsArr.isEmpty()) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: 'actors' is empty", fileId); + } else { + List actorConstraints = new ArrayList<>(); + + for (int i = 0; i < actorsArr.size(); i++) { + JsonObject a = actorsArr.get(i).getAsJsonObject(); + warnUnknownKeys(a, fileId, "actor[" + i + "]", KNOWN_ACTOR_KEYS); + Set types = readEntityTypes(a, fileId); + if (types == null) { + actorConstraints = null; + break; + } + + String entityVariant = readEntityVariant(a, fileId, "actor[" + i + "]"); + if ("\u0000".equals(entityVariant)) { + actorConstraints = null; + break; + } + + Set reqTags = readStringArray(a, "actor_tags"); + Set reqTagsAny = readStringArray(a, "actor_tags_any"); + String label = a.has("label") ? a.get("label").getAsString() : null; + AnimationDefinitions.AgeRequirement ageRequirement = readAgeRequirement(a, fileId); + if (ageRequirement == null) { + actorConstraints = null; + break; + } + + AnimationDefinitions.ActorActivity activity = readActorActivity(a, fileId); + Identifier propLeftItemId = readOptionalItemId(a, "prop_left", fileId, "actor[" + i + "]"); + Identifier propRightItemId = readOptionalItemId(a, "prop_right", fileId, "actor[" + i + "]"); + Identifier propFloorItemId = readOptionalItemId(a, "prop_floor", fileId, "actor[" + i + "]"); + actorConstraints.add( + new AnimationDefinitions.ActorConstraint( + Set.copyOf(types), + entityVariant, + Set.copyOf(reqTags), + Set.copyOf(reqTagsAny), + label, + ageRequirement, + activity, + propLeftItemId, + propRightItemId, + propFloorItemId + ) + ); + } + + if (actorConstraints != null) { + Set animationTags = normalizeTags(readStringArray(obj, "animation_tags")); + List contentTags = normalizeTagList(readStringArray(obj, "content_tags")); + Set requiredUnionTags = readStringArray(obj, "required_union_tags"); + double weight = readWeightOrDefault(obj, fileId); + String displayName = readOptionalDisplayName(obj, fileId); + Identifier defId = definitionIdFromFile(fileId); + Set joinAfter = readJoinAfter(obj, fileId, defId); + AnimationDefinitions.BlockRequirements blockRequirements = readBlockRequirements(obj, fileId); + AnimationDefinitions.WaterRequirement waterRequirement = readWaterRequirement(obj, fileId); + String positionAnchorActor = readOptionalAnchorActorKey(obj, "position_anchor_actor"); + List stages = readStageInfos(obj, defId, fileId, actorConstraints); + if (stages.isEmpty()) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: missing 'stages' array", fileId); + } else { + AnimationDefinitions.Definition definition = new AnimationDefinitions.Definition( + defId, + List.copyOf(actorConstraints), + Set.copyOf(requiredUnionTags), + Set.copyOf(animationTags), + contentTags, + stages, + weight, + blockRequirements, + waterRequirement, + positionAnchorActor, + packInfo, + displayName, + joinAfter + ); + definition = definition.withGameplayMetadata( + AnimationGameplayMetadata.parse(fileId, definition, obj, List.of(buildUniqueActorKeys(actorConstraints))) + ); + loaded.add(definition); + } + } + } + } + } catch (IOException | RuntimeException e) { + AnimationEngine.logSetupError("[NoN Animation] Failed to load {}", fileId, e); + } + } + + loaded.sort(Comparator.comparing(d -> d.id().toString())); + validateJoinAfterReferences(loaded); + DEFINITIONS = List.copyOf(loaded); + Map definitionsById = new LinkedHashMap<>(); + DEFINITIONS.forEach(definitionx -> definitionsById.put(definitionx.id(), definitionx)); + DEFINITIONS_BY_ID = Map.copyOf(definitionsById); + rebuildMatchingIndexes(); + JOIN_FOLLOWERS_BY_PREDECESSOR = buildJoinFollowerIndex(DEFINITIONS); + rebuildEligibilityIndexes(); + AnimationEngine.LOGGER.info("[NoN Animation] Loaded {} animation definition(s) from datapacks", DEFINITIONS.size()); + } + + private static void rebuildMatchingIndexes() { + Map> byActorCount = new LinkedHashMap<>(); + Map> matchOrders = new LinkedHashMap<>(); + + for (AnimationDefinitions.Definition definition : DEFINITIONS) { + byActorCount.computeIfAbsent(definition.actors().size(), ignored -> new ArrayList<>()).add(definition); + matchOrders.put(definition.id(), buildConstraintOrder(definition.actors())); + } + + Comparator order = Comparator.comparing(definition -> definition.id().toString()); + Map> finalized = new LinkedHashMap<>(); + byActorCount.forEach((actorCount, definitions) -> { + definitions.sort(order); + finalized.put(actorCount, List.copyOf((Collection)definitions)); + }); + DEFINITIONS_BY_ACTOR_COUNT = Map.copyOf(finalized); + MATCH_ORDER_BY_DEFINITION = Map.copyOf(matchOrders); + } + + private static void rebuildEligibilityIndexes() { + Set actorTypes = new LinkedHashSet<>(); + Set pairTypes = new LinkedHashSet<>(); + Map> compatibility = new LinkedHashMap<>(); + + for (AnimationDefinitions.Definition definition : DEFINITIONS) { + if (isDefinitionEnabled(definition) && isGenericMatchDefinition(definition)) { + List actors = definition.actors(); + if (actors != null && actors.size() >= 2) { + List> keysByActor = actors.stream().map(actor -> { + boolean baby = actor.ageRequirement() == AnimationDefinitions.AgeRequirement.BABY; + Set keys = new LinkedHashSet<>(); + + for (Identifier entityType : actor.entityTypes()) { + AnimationDefinitions.ActorKey key = new AnimationDefinitions.ActorKey(entityType, baby); + keys.add(key); + actorTypes.add(key); + } + + return Set.copyOf(keys); + }).toList(); + if (keysByActor.size() == 2 && !keysByActor.get(0).isEmpty() && !keysByActor.get(1).isEmpty()) { + for (AnimationDefinitions.ActorKey left : keysByActor.get(0)) { + for (AnimationDefinitions.ActorKey right : keysByActor.get(1)) { + pairTypes.add(left); + pairTypes.add(right); + compatibility.computeIfAbsent(left, ignored -> new LinkedHashSet<>()).add(right); + compatibility.computeIfAbsent(right, ignored -> new LinkedHashSet<>()).add(left); + } + } + } + } + } + } + + Map> immutableCompatibility = new LinkedHashMap<>(); + compatibility.forEach((key, value) -> immutableCompatibility.put(key, Set.copyOf((Collection)value))); + GENERIC_ACTOR_TYPES = Set.copyOf(actorTypes); + GENERIC_MOB_PAIR_TYPES = Set.copyOf(pairTypes); + GENERIC_MOB_PAIR_COMPATIBILITY = Map.copyOf(immutableCompatibility); + } + + private static Map readAnimationPackInfos(ResourceManager resourceManager) { + LinkedHashMap out = new LinkedHashMap<>(); + if (resourceManager == null) { + return out; + } + + try (Stream stream = resourceManager.streamResourcePacks()) { + stream.forEach(pack -> { + if (pack != null) { + String resourcePackId = pack.getId(); + if (resourcePackId != null && !resourcePackId.isBlank()) { + AnimationDefinitions.AnimationPackInfo info = readAnimationPackInfo(pack, resourcePackId); + out.put(resourcePackId, info == null ? fallbackPackInfo(resourcePackId) : info); + } + } + }); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to read animation pack metadata from pack.mcmeta.", e); + } + + validateModelProviders(out.values()); + return out; + } + + private static void validateModelProviders(Collection packInfos) { + if (packInfos != null && !packInfos.isEmpty()) { + Map ownerByRoot = new LinkedHashMap<>(); + Set availablePackIds = new LinkedHashSet<>(); + + for (AnimationDefinitions.AnimationPackInfo info : packInfos) { + if (info != null && info.localModelRoot() != null) { + availablePackIds.add(info.id()); + String previousOwner = ownerByRoot.putIfAbsent(info.localModelRoot(), info.resourcePackId()); + if (previousOwner != null && !previousOwner.equals(info.resourcePackId())) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Local model root {} is shared by resource packs '{}' and '{}'. Give each pack a unique animationframework.id.", + info.localModelRoot(), + previousOwner, + info.resourcePackId() + ); + } + } + } + + for (AnimationDefinitions.AnimationPackInfo info : packInfos) { + if (info != null) { + for (AnimationDefinitions.ModelImport modelImport : info.modelImports()) { + if (!availablePackIds.contains(modelImport.packId())) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Pack '{}' imports models from missing pack '{}'. Missing models will fall back to shared resources.", + info.id(), + modelImport.packId() + ); + } + } + } + } + } + } + + @Nullable + private static AnimationDefinitions.AnimationPackInfo readAnimationPackInfo(ResourcePack pack, String resourcePackId) { + try (InputStream stream = openRootResource(pack, "pack.mcmeta")) { + if (stream == null) { + return null; + } + + InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8); + + JsonObject root; + try { + root = JsonParser.parseReader(reader).getAsJsonObject(); + } catch (Throwable var17) { + try { + reader.close(); + } catch (Throwable var16) { + var17.addSuppressed(var16); + } + + throw var17; + } + + reader.close(); + JsonObject var21 = null; + if (root.has("animationframework") && root.get("animationframework").isJsonObject()) { + var21 = root.getAsJsonObject("animationframework"); + } + + String packDescription = readPackDescription(root); + if (var21 == null) { + AnimationDefinitions.AnimationPackInfo fallback = fallbackPackInfo(resourcePackId); + return new AnimationDefinitions.AnimationPackInfo( + fallback.id(), resourcePackId, fallback.name(), fallback.author(), fallback.version(), packDescription, null + ); + } + + String rawId = readOptionalString(var21, "id"); + String id = sanitizePackMetadataId(rawId, resourcePackId); + String name = readOptionalString(var21, "name"); + String author = readOptionalString(var21, "author"); + String version = readOptionalString(var21, "version"); + String description = readOptionalString(var21, "description"); + if (description == null) { + description = packDescription; + } + + Identifier localModelRoot = localModelRoot(rawId); + warnRemovedModelScope(var21, resourcePackId); + List modelImports = readModelImports(var21, resourcePackId, rawId, true); + return new AnimationDefinitions.AnimationPackInfo(id, resourcePackId, name, author, version, description, localModelRoot, modelImports); + } catch (IOException | RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN Animation] Failed to parse animation pack metadata for {}", resourcePackId, e); + return null; + } + } + + @Nullable + private static InputStream openRootResource(ResourcePack pack, String fileName) { + if (pack != null && fileName != null) { + InputSupplier supplier = pack.openRoot(new String[]{fileName}); + if (supplier == null) { + return null; + } + + try { + return (InputStream)supplier.get(); + } catch (IOException | RuntimeException e) { + return null; + } + } else { + return null; + } + } + + private static AnimationDefinitions.AnimationPackInfo fallbackPackInfo(String resourcePackId) { + String safePackId = sanitizeNonBlank(resourcePackId, "unknown"); + return new AnimationDefinitions.AnimationPackInfo(safePackId, safePackId, displayNameFromPackId(safePackId), "Unknown", null, null, null); + } + + @Nullable + private static Identifier localModelRoot(@Nullable String rawPackId) { + String cleaned = sanitizeNullable(rawPackId); + Identifier packId = cleaned != null && cleaned.indexOf(58) > 0 ? Identifier.tryParse(cleaned) : null; + return packId == null ? null : Identifier.of(packId.getNamespace(), packId.getPath() + "/entity"); + } + + private static void warnRemovedModelScope(JsonObject animationMetadata, String resourcePackId) { + if (animationMetadata != null && animationMetadata.has("model_scope")) { + AnimationEngine.logSetupWarning( + "[NoN Animation] animationframework.model_scope in pack.mcmeta for {} is no longer needed and has been ignored. All packs now use local models automatically.", + resourcePackId + ); + } + } + + static List readModelImports( + JsonObject animationMetadata, String resourcePackId, @Nullable String ownPackId, boolean logWarnings + ) { + if (animationMetadata != null && animationMetadata.has("model_imports")) { + JsonElement importsElement = animationMetadata.get("model_imports"); + if (importsElement != null && importsElement.isJsonArray()) { + List imports = new ArrayList<>(); + Set seenMappings = new LinkedHashSet<>(); + Identifier ownId = ownPackId != null && ownPackId.indexOf(58) > 0 ? Identifier.tryParse(ownPackId) : null; + + for (JsonElement importElement : importsElement.getAsJsonArray()) { + if (importElement != null && importElement.isJsonObject()) { + JsonObject importObject = importElement.getAsJsonObject(); + String importedPackId = readOptionalString(importObject, "pack"); + Identifier importedId = importedPackId != null && importedPackId.indexOf(58) > 0 ? Identifier.tryParse(importedPackId) : null; + if (importedId == null) { + logPackMetadataWarning( + logWarnings, + "[NoN Animation] Ignoring invalid model import pack id '{}' in pack.mcmeta for {}. Use a namespaced id like creator:model_pack.", + importedPackId, + resourcePackId + ); + } else if (importedId.equals(ownId)) { + logPackMetadataWarning( + logWarnings, "[NoN Animation] Ignoring self model import '{}' in pack.mcmeta for {}.", importedPackId, resourcePackId + ); + } else { + JsonElement entitiesElement = importObject.get("entities"); + if (entitiesElement != null && entitiesElement.isJsonArray()) { + LinkedHashSet entityTypes = new LinkedHashSet<>(); + + for (JsonElement entityElement : entitiesElement.getAsJsonArray()) { + String rawEntity = entityElement != null && entityElement.isJsonPrimitive() && entityElement.getAsJsonPrimitive().isString() + ? entityElement.getAsString().trim() + : null; + Identifier entityId = rawEntity != null && rawEntity.indexOf(58) > 0 ? Identifier.tryParse(rawEntity) : null; + if (entityId == null) { + logPackMetadataWarning( + logWarnings, + "[NoN Animation] Ignoring invalid entity '{}' in model import '{}' for {}.", + rawEntity, + importedPackId, + resourcePackId + ); + } else { + String mappingKey = importedId + "|" + entityId; + if (seenMappings.add(mappingKey)) { + entityTypes.add(entityId); + } + } + } + + if (entityTypes.isEmpty()) { + logPackMetadataWarning( + logWarnings, + "[NoN Animation] Ignoring model import '{}' in pack.mcmeta for {} because it contains no valid entities.", + importedPackId, + resourcePackId + ); + } else { + imports.add( + new AnimationDefinitions.ModelImport( + importedId.toString(), Identifier.of(importedId.getNamespace(), importedId.getPath() + "/entity"), entityTypes + ) + ); + } + } else { + logPackMetadataWarning( + logWarnings, + "[NoN Animation] Ignoring model import '{}' in pack.mcmeta for {}: 'entities' must be a non-empty array.", + importedPackId, + resourcePackId + ); + } + } + } else { + logPackMetadataWarning( + logWarnings, + "[NoN Animation] Ignoring invalid model_imports entry in pack.mcmeta for {}: expected an object with 'pack' and 'entities'.", + resourcePackId + ); + } + } + + return imports.isEmpty() ? List.of() : List.copyOf(imports); + } else { + logPackMetadataWarning( + logWarnings, "[NoN Animation] Ignoring invalid animationframework.model_imports in pack.mcmeta for {}: expected an array.", resourcePackId + ); + return List.of(); + } + } else { + return List.of(); + } + } + + private static void logPackMetadataWarning(boolean enabled, String template, Object... args) { + if (enabled) { + AnimationEngine.logSetupWarning(template, args); + } + } + + private static String sanitizePackMetadataId(@Nullable String rawId, String resourcePackId) { + String fallback = sanitizeNonBlank(resourcePackId, "unknown"); + String cleaned = sanitizeNullable(rawId); + if (cleaned == null) { + return fallback; + } else if (Identifier.tryParse(cleaned) == null) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Ignoring invalid animationframework.id '{}' in pack.mcmeta for {}. Use a namespaced id like creator:pack_name.", + cleaned, + resourcePackId + ); + return fallback; + } else { + return cleaned; + } + } + + @Nullable + private static String readPackDescription(JsonObject root) { + if (root != null && root.has("pack") && root.get("pack").isJsonObject()) { + JsonObject pack = root.getAsJsonObject("pack"); + return readOptionalString(pack, "description"); + } else { + return null; + } + } + + @Nullable + private static String readOptionalString(JsonObject object, String key) { + if (object != null && key != null && object.has(key)) { + JsonElement element = object.get(key); + if (element != null && element.isJsonPrimitive()) { + try { + return sanitizeNullable(element.getAsString()); + } catch (RuntimeException e) { + return null; + } + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static String readOptionalDisplayName(JsonObject object, Identifier fileId) { + if (object != null && object.has("display_name")) { + JsonElement element = object.get("display_name"); + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String value = element.getAsString().trim(); + if (value.isEmpty()) { + AnimationEngine.logSetupWarning("[NoN Animation] Ignoring blank display_name in {}. The animation id will be shown instead.", fileId); + return null; + } else { + return value; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Ignoring invalid display_name in {}: expected a non-empty string.", fileId); + return null; + } + } else { + return null; + } + } + + private static String displayNameFromPackId(String rawPackId) { + String id = sanitizeNonBlank(rawPackId, "Unknown Pack"); + int slash = id.lastIndexOf(47); + if (slash >= 0 && slash < id.length() - 1) { + id = id.substring(slash + 1); + } + + int colon = id.indexOf(58); + if (colon >= 0 && colon < id.length() - 1) { + id = id.substring(colon + 1); + } + + int underscore = id.indexOf(95); + if (underscore > 0 && isNumericPrefix(id, underscore)) { + id = id.substring(underscore + 1); + } + + id = id.replace('_', ' ').replace('-', ' ').trim(); + return id.isBlank() ? "Unknown Pack" : id; + } + + private static boolean isNumericPrefix(String value, int endExclusive) { + if (value != null && endExclusive > 0 && endExclusive <= value.length()) { + for (int i = 0; i < endExclusive; i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + + return true; + } else { + return false; + } + } + + private static String sanitizeNonBlank(@Nullable String value, String fallback) { + String cleaned = sanitizeNullable(value); + return cleaned == null ? fallback : cleaned; + } + + @Nullable + private static String sanitizeNullable(@Nullable String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static void warnUnknownKeys(JsonObject obj, Identifier fileId, String context, Set knownKeys) { + if (obj != null && knownKeys != null && !knownKeys.isEmpty()) { + String safeContext = context != null && !context.isBlank() ? context : "object"; + + for (Entry entry : obj.entrySet()) { + String key = entry.getKey(); + if (key != null && !knownKeys.contains(key)) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Unknown field '{}' in {} {}. It will be ignored; check for typos.", key, fileId == null ? "" : fileId, safeContext + ); + } + } + } + } + + private static Set readStringArray(JsonObject obj, String key) { + if (!obj.has(key)) { + return new LinkedHashSet<>(); + } + + JsonArray arr = obj.getAsJsonArray(key); + Set out = new LinkedHashSet<>(); + + for (int i = 0; i < arr.size(); i++) { + out.add(arr.get(i).getAsString()); + } + + return out; + } + + private static double readWeightOrDefault(JsonObject obj, Identifier fileId) { + if (obj != null && obj.has("weight")) { + JsonElement element = obj.get("weight"); + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber()) { + double value; + try { + value = element.getAsDouble(); + } catch (RuntimeException ignored) { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid weight in {} (must be a positive number). Using default 1.0.", fileId); + return 1.0; + } + + if (Double.isFinite(value) && !(value <= 0.0)) { + return value; + } + + AnimationEngine.logSetupWarning("[NoN Animation] Invalid weight '{}' in {} (must be > 0). Using default 1.0.", value, fileId); + return 1.0; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid weight in {} (must be a positive number). Using default 1.0.", fileId); + return 1.0; + } + } else { + return 1.0; + } + } + + @Nullable + private static String readOptionalAnchorActorKey(JsonObject obj, String key) { + if (obj == null || key == null || key.isBlank()) { + return null; + } + + if (!obj.has(key)) { + return null; + } + + JsonElement element = obj.get(key); + if (element != null && element.isJsonPrimitive()) { + String raw = element.getAsString(); + if (raw == null) { + return null; + } + + String trimmed = raw.trim(); + return trimmed.isEmpty() ? null : trimmed; + } else { + return null; + } + } + + private static Set normalizeTags(Set tags) { + if (tags != null && !tags.isEmpty()) { + Set out = new LinkedHashSet<>(); + + for (String raw : tags) { + if (raw != null) { + String value = raw.trim().toLowerCase(Locale.ROOT); + if (!value.isEmpty()) { + out.add(value); + } + } + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } else { + return Set.of(); + } + } + + private static AnimationDefinitions.AgeRequirement readAgeRequirement(JsonObject obj, Identifier fileId) { + if (!obj.has("age")) { + return AnimationDefinitions.AgeRequirement.ADULT; + } + + String raw = obj.get("age").getAsString(); + if (raw != null && !raw.isBlank()) { + String value = raw.toLowerCase(Locale.ROOT).trim(); + + return switch (value) { + case "adult" -> AnimationDefinitions.AgeRequirement.ADULT; + case "baby" -> AnimationDefinitions.AgeRequirement.BABY; + default -> { + AnimationEngine.logSetupWarning("[NoN Animation] Unknown age '{}' in {} (expected adult|baby). Skipping definition.", raw, fileId); + yield null; + } + }; + } else { + return AnimationDefinitions.AgeRequirement.ADULT; + } + } + + private static AnimationDefinitions.ActorActivity readActorActivity(JsonObject obj, Identifier fileId) { + if (!obj.has("activity")) { + return AnimationDefinitions.ActorActivity.ACTIVE; + } + + String raw = obj.get("activity").getAsString(); + if (raw != null && !raw.isBlank()) { + String value = raw.toLowerCase(Locale.ROOT).trim(); + + return switch (value) { + case "active" -> AnimationDefinitions.ActorActivity.ACTIVE; + case "passive" -> AnimationDefinitions.ActorActivity.PASSIVE; + default -> { + AnimationEngine.logSetupWarning("[NoN Animation] Unknown actor activity '{}' in {} (expected active|passive). Using active.", raw, fileId); + yield AnimationDefinitions.ActorActivity.ACTIVE; + } + }; + } else { + return AnimationDefinitions.ActorActivity.ACTIVE; + } + } + + static AnimationDefinitions.BlockRequirements readBlockRequirements(JsonObject obj, Identifier fileId) { + if (!obj.has("block_requirements")) { + return null; + } + + JsonObject req = obj.getAsJsonObject("block_requirements"); + if (req == null) { + return null; + } + + warnUnknownKeys(req, fileId, "block_requirements", KNOWN_BLOCK_REQUIREMENT_KEYS); + String type = req.has("type") ? req.get("type").getAsString() : "wall"; + String normalizedType = type == null ? "wall" : type.trim().toLowerCase(Locale.ROOT); + AnimationDefinitions.BlockPredicate blocks = readBlockPredicate(req, fileId); + if (req.has("blocks") && blocks == null) { + return null; + } + + if ("wall".equals(normalizedType)) { + AnimationDefinitions.WallHeight height = readWallHeight(req, fileId); + if (height == null) { + return null; + } + + AnimationDefinitions.WallWidth width = readWallWidth(req, fileId); + if (width == null) { + return null; + } + + AnimationDefinitions.WallFacing facing = readWallFacing(req, fileId, blocks); + if (req.has("facing") && facing == null) { + return null; + } + + AnimationDefinitions.Clearance clearance = readClearance(req, fileId); + return new AnimationDefinitions.BlockRequirements("wall", height, width, facing, clearance, null, null, 0, null, blocks); + } else if ("center_support".equals(normalizedType)) { + if (req.has("facing")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements facing is only supported for walls.", fileId); + return null; + } + + if (req.has("width")) { + AnimationEngine.logSetupWarning("[NoN Animation] {}: center_support ignores wall 'width'.", fileId); + } + + AnimationDefinitions.CenterSupport support = readCenterSupport(req, fileId); + if (support == null) { + return null; + } + + if (support != AnimationDefinitions.CenterSupport.SURFACE || blocks != null && !blocks.isEmpty()) { + AnimationDefinitions.CenterPlacement placement = readCenterPlacement(req, fileId); + if (placement == null) { + return null; + } + + AnimationDefinitions.Clearance clearance = null; + int surfaceRadius = 0; + AnimationDefinitions.SurfaceFootprint surfaceFootprint = null; + if (placement == AnimationDefinitions.CenterPlacement.SURFACE_FOOTPRINT) { + surfaceFootprint = readSurfaceFootprint(req, fileId); + if (surfaceFootprint == null) { + return null; + } + + if (req.has("clearance")) { + AnimationEngine.logSetupWarning("[NoN Animation] {}: center_support surface_footprint ignores 'clearance'.", fileId); + } + + if (req.has("surface_radius")) { + AnimationEngine.logSetupWarning("[NoN Animation] {}: center_support surface_footprint ignores 'surface_radius'.", fileId); + } + } else { + clearance = readClearance(req, fileId); + surfaceRadius = readNonNegativeInt(req, "surface_radius", 0); + if (req.has("surface_footprint")) { + AnimationEngine.logSetupWarning("[NoN Animation] {}: center_support directional_clearance ignores 'surface_footprint'.", fileId); + } + } + + return new AnimationDefinitions.BlockRequirements( + "center_support", null, null, null, clearance, support, placement, surfaceRadius, surfaceFootprint, blocks + ); + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support surface requires 'blocks'.", fileId); + return null; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: unsupported block_requirements type '{}'", fileId, type); + return null; + } + } + + @Nullable + private static AnimationDefinitions.WallFacing readWallFacing(JsonObject req, Identifier fileId, @Nullable AnimationDefinitions.BlockPredicate blocks) { + if (!req.has("facing")) { + return null; + } + + if (blocks != null && !blocks.isEmpty()) { + JsonElement element = req.get("facing"); + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString(); + String value = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + + for (AnimationDefinitions.WallFacing facing : AnimationDefinitions.WallFacing.values()) { + if (facing.id().equals(value)) { + return facing; + } + } + + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: unsupported wall facing '{}' (expected front|back|left|right).", fileId, raw); + return null; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: wall block_requirements facing must be a string.", fileId); + return null; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: wall block_requirements facing requires 'blocks'.", fileId); + return null; + } + } + + private static AnimationDefinitions.CenterPlacement readCenterPlacement(JsonObject req, Identifier fileId) { + if (!req.has("placement")) { + return AnimationDefinitions.CenterPlacement.DIRECTIONAL_CLEARANCE; + } + + String raw = req.get("placement").getAsString(); + if (raw != null && !raw.isBlank()) { + String value = raw.trim().toLowerCase(Locale.ROOT); + + for (AnimationDefinitions.CenterPlacement placement : AnimationDefinitions.CenterPlacement.values()) { + if (placement.id().equals(value)) { + return placement; + } + } + + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: unsupported center_support placement '{}'", fileId, raw); + return null; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support block_requirements has empty placement", fileId); + return null; + } + } + + private static AnimationDefinitions.CenterSupport readCenterSupport(JsonObject req, Identifier fileId) { + if (!req.has("support")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support block_requirements missing support", fileId); + return null; + } + + String raw = req.get("support").getAsString(); + if (raw != null && !raw.isBlank()) { + String value = raw.trim().toLowerCase(Locale.ROOT); + + for (AnimationDefinitions.CenterSupport support : AnimationDefinitions.CenterSupport.values()) { + if (support.id().equals(value)) { + return support; + } + } + + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: unsupported center_support support '{}'", fileId, raw); + return null; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support block_requirements has empty support", fileId); + return null; + } + } + + @Nullable + private static AnimationDefinitions.SurfaceFootprint readSurfaceFootprint(JsonObject req, Identifier fileId) { + if (!req.has("surface_footprint")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support surface_footprint placement requires 'surface_footprint'.", fileId); + return null; + } else { + JsonElement element = req.get("surface_footprint"); + if (element != null && element.isJsonObject()) { + JsonObject obj = element.getAsJsonObject(); + warnUnknownKeys(obj, fileId, "block_requirements.surface_footprint", KNOWN_SURFACE_FOOTPRINT_KEYS); + int width = readPositiveInt(obj, "width", 1); + int depth = readPositiveInt(obj, "depth", 1); + int height = readPositiveInt(obj, "height", 2); + int margin = readNonNegativeInt(obj, "margin", 0); + return new AnimationDefinitions.SurfaceFootprint(width, depth, height, margin); + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: center_support surface_footprint must be an object.", fileId); + return null; + } + } + } + + @Nullable + private static AnimationDefinitions.BlockPredicate readBlockPredicate(JsonObject req, Identifier fileId) { + if (!req.has("blocks")) { + return null; + } + + JsonElement blocksElement = req.get("blocks"); + if (blocksElement != null && !blocksElement.isJsonNull()) { + Set blockIds = new LinkedHashSet<>(); + Set tagIds = new LinkedHashSet<>(); + if (blocksElement.isJsonArray()) { + JsonArray arr = blocksElement.getAsJsonArray(); + if (arr.isEmpty()) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements blocks array is empty.", fileId); + return null; + } + + for (JsonElement element : arr) { + if (!readBlockPredicateEntry(element, fileId, blockIds, tagIds)) { + return null; + } + } + } else if (!readBlockPredicateEntry(blocksElement, fileId, blockIds, tagIds)) { + return null; + } + + if (blockIds.isEmpty() && tagIds.isEmpty()) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements blocks did not contain any valid entries.", fileId); + return null; + } else { + return new AnimationDefinitions.BlockPredicate(Set.copyOf(blockIds), Set.copyOf(tagIds)); + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements blocks must not be null.", fileId); + return null; + } + } + + private static boolean readBlockPredicateEntry(JsonElement element, Identifier fileId, Set blockIds, Set tagIds) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString(); + if (raw != null && !raw.isBlank()) { + String trimmed = raw.trim(); + boolean tag = trimmed.startsWith("#"); + String idText = tag ? trimmed.substring(1) : trimmed; + if (!idText.contains(":")) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Skipping {}: block_requirements blocks entry '{}' must use a namespaced id like 'minecraft:chest' or '#minecraft:beds'.", + fileId, + raw + ); + return false; + } + + Identifier id = Identifier.tryParse(idText); + if (id == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements blocks identifier '{}'.", fileId, raw); + return false; + } + + if (tag) { + tagIds.add(id); + } else { + if (!Registries.BLOCK.containsId(id)) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: unknown block id '{}' in block_requirements blocks.", fileId, raw); + return false; + } + + blockIds.add(id); + } + + return true; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements blocks contains an empty entry.", fileId); + return false; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements blocks entries must be strings.", fileId); + return false; + } + } + + private static AnimationDefinitions.WaterRequirement readWaterRequirement(JsonObject obj, Identifier fileId) { + if (!obj.has("water")) { + return AnimationDefinitions.WaterRequirement.NONE; + } + + JsonElement el = obj.get("water"); + if (el != null && el.isJsonPrimitive()) { + String raw = el.getAsString(); + if (raw != null && !raw.isBlank()) { + String value = raw.toLowerCase(Locale.ROOT).trim(); + + return switch (value) { + case "surface" -> AnimationDefinitions.WaterRequirement.SURFACE; + case "underwater" -> AnimationDefinitions.WaterRequirement.UNDERWATER; + case "none" -> AnimationDefinitions.WaterRequirement.NONE; + default -> { + AnimationEngine.logSetupWarning("[NoN Animation] Unknown water requirement '{}' in {}; expected none|surface|underwater.", raw, fileId); + yield AnimationDefinitions.WaterRequirement.NONE; + } + }; + } else { + return AnimationDefinitions.WaterRequirement.NONE; + } + } else { + return AnimationDefinitions.WaterRequirement.NONE; + } + } + + private static AnimationDefinitions.WallHeight readWallHeight(JsonObject req, Identifier fileId) { + int min = 2; + Integer max = null; + boolean minSet = false; + if (req.has("height")) { + JsonElement heightEl = req.get("height"); + if (heightEl.isJsonObject()) { + JsonObject heightObj = heightEl.getAsJsonObject(); + warnUnknownKeys(heightObj, fileId, "block_requirements.height", KNOWN_WALL_HEIGHT_KEYS); + Integer minVal = readOptionalInt(heightObj, "min"); + Integer maxVal = readOptionalInt(heightObj, "max"); + if (minVal != null) { + min = minVal; + minSet = true; + } + + if (maxVal != null) { + max = maxVal; + if (!minSet) { + min = 1; + } + } + } else { + Integer exact = readOptionalInt(heightEl); + if (exact != null) { + min = exact; + max = exact; + } + } + } + + if (min < 1) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements height min '{}'", fileId, min); + return null; + } + + if (max != null) { + if (max < 1) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements height max '{}'", fileId, max); + return null; + } + + if (max < min) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements height max '{}' < min '{}'", fileId, max, min); + return null; + } + } + + return new AnimationDefinitions.WallHeight(min, max); + } + + static AnimationDefinitions.WallWidth readWallWidth(JsonObject req, Identifier fileId) { + if (!req.has("width")) { + return new AnimationDefinitions.WallWidth(1, null); + } + + int min = 1; + Integer max = null; + JsonElement widthElement = req.get("width"); + if (widthElement.isJsonObject()) { + JsonObject widthObject = widthElement.getAsJsonObject(); + warnUnknownKeys(widthObject, fileId, "block_requirements.width", KNOWN_WALL_WIDTH_KEYS); + if (!widthObject.has("min") && !widthObject.has("max")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements width must define 'min' or 'max'.", fileId); + return null; + } + + Integer minValue = readOptionalInt(widthObject, "min"); + Integer maxValue = readOptionalInt(widthObject, "max"); + if (widthObject.has("min") && minValue == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements width min.", fileId); + return null; + } + + if (widthObject.has("max") && maxValue == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements width max.", fileId); + return null; + } + + if (minValue != null) { + min = minValue; + } + + max = maxValue; + } else { + Integer minimum = readOptionalInt(widthElement); + if (minimum == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements width.", fileId); + return null; + } + + min = minimum; + } + + if (min < 1) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid block_requirements width min '{}'.", fileId, min); + return null; + } else if (max != null && max < min) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: block_requirements width max '{}' < min '{}'.", fileId, max, min); + return null; + } else { + return new AnimationDefinitions.WallWidth(min, max); + } + } + + static AnimationDefinitions.Clearance readClearance(JsonObject req, Identifier fileId) { + int width = 1; + int height = 2; + int depth = 1; + if (req.has("clearance")) { + JsonObject clearance = req.getAsJsonObject("clearance"); + if (clearance != null) { + warnUnknownKeys(clearance, fileId, "block_requirements.clearance", KNOWN_CLEARANCE_KEYS); + width = readPositiveInt(clearance, "width", width); + height = readPositiveInt(clearance, "height", height); + depth = readPositiveInt(clearance, "depth", depth); + } + } + + return new AnimationDefinitions.Clearance(width, height, depth); + } + + private static int readPositiveInt(JsonObject obj, String key, int fallback) { + if (!obj.has(key)) { + return fallback; + } else { + Integer value = readOptionalInt(obj, key); + if (value == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid integer value for {}", key); + return fallback; + } else { + return Math.max(1, value); + } + } + } + + private static int readNonNegativeInt(JsonObject obj, String key, int fallback) { + if (!obj.has(key)) { + return fallback; + } else { + Integer value = readOptionalInt(obj, key); + if (value == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid integer value for {}", key); + return fallback; + } else { + return Math.max(0, value); + } + } + } + + private static Integer readOptionalInt(JsonObject obj, String key) { + return !obj.has(key) ? null : readOptionalInt(obj.get(key)); + } + + private static Set readEntityTypes(JsonObject obj, Identifier fileId) { + String key = "entity_types"; + if (!obj.has(key)) { + return new LinkedHashSet<>(); + } + + JsonArray arr = obj.getAsJsonArray(key); + Set out = new LinkedHashSet<>(); + + for (int i = 0; i < arr.size(); i++) { + String s = arr.get(i).getAsString(); + Identifier id = Identifier.tryParse(s); + if (id == null) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: invalid identifier in {}: '{}'", fileId, key, s); + return null; + } + + if (PLAYER_SLIM_ID.equals(id)) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Skipping {}: '{}' is no longer supported in {}. Use '{}' only.", fileId, PLAYER_SLIM_ID, key, PLAYER_ID + ); + return null; + } + + out.add(id); + } + + return out; + } + + private static Set readJoinAfter(JsonObject obj, Identifier fileId, Identifier definitionId) { + if (!obj.has("join_after")) { + return Set.of(); + } + + JsonElement element = obj.get("join_after"); + if (element != null && element.isJsonArray()) { + LinkedHashSet out = new LinkedHashSet<>(); + JsonArray values = element.getAsJsonArray(); + + for (int i = 0; i < values.size(); i++) { + JsonElement value = values.get(i); + if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + String raw = value.getAsString().trim(); + Identifier id = raw.indexOf(58) > 0 ? Identifier.tryParse(raw) : null; + if (id == null) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Invalid join_after entry '{}' in {}: use an exact namespaced id such as creator:wolfmplayer.", raw, fileId + ); + } else if (id.equals(definitionId)) { + AnimationEngine.logSetupWarning("[NoN Animation] Ignoring self-referencing join_after entry '{}' in {}.", id, fileId); + } else { + out.add(id); + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid join_after entry {} in {}: expected an exact namespaced animation id.", i, fileId); + } + } + + return Set.copyOf(out); + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid join_after in {}: expected an array of exact namespaced animation ids.", fileId); + return Set.of(); + } + } + + private static void validateJoinAfterReferences(List definitions) { + Set loadedIds = new HashSet<>(); + + for (AnimationDefinitions.Definition definition : definitions) { + loadedIds.add(definition.id()); + } + + for (AnimationDefinitions.Definition definition : definitions) { + for (Identifier predecessorId : definition.joinAfter()) { + if (!loadedIds.contains(predecessorId)) { + AnimationEngine.logSetupWarning("[NoN Animation] Animation {} references missing join_after predecessor {}.", definition.id(), predecessorId); + } + } + } + } + + private static Map> buildJoinFollowerIndex(List definitions) { + LinkedHashMap> mutable = new LinkedHashMap<>(); + + for (AnimationDefinitions.Definition definition : definitions) { + for (Identifier predecessorId : definition.joinAfter()) { + mutable.computeIfAbsent(predecessorId, ignored -> new ArrayList<>()).add(definition); + } + } + + LinkedHashMap> immutable = new LinkedHashMap<>(); + mutable.forEach((id, followers) -> immutable.put(id, List.copyOf((Collection)followers))); + return Map.copyOf(immutable); + } + + @Nullable + private static String readEntityVariant(JsonObject obj, Identifier fileId, String contextLabel) { + String key = "entity_variant"; + if (obj != null && obj.has(key)) { + JsonElement el = obj.get(key); + if (el == null || el.isJsonNull()) { + return null; + } + + if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) { + String value = el.getAsString(); + if (value != null && !value.isBlank()) { + value = value.trim().toLowerCase(Locale.ROOT); + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean valid = c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_'; + if (!valid) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Skipping {}: invalid {} '{}' in {}. Use lowercase letters, numbers, and underscores.", + fileId, + key, + value, + contextLabel + ); + return "\u0000"; + } + } + + return value; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: {} in {} must not be blank.", fileId, key, contextLabel); + return "\u0000"; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: {} in {} must be a string.", fileId, key, contextLabel); + return "\u0000"; + } + } else { + return null; + } + } + + @Nullable + private static Identifier readOptionalItemId(JsonObject obj, String key, Identifier fileId, String contextLabel) { + if (obj != null && obj.has(key)) { + JsonElement el = obj.get(key); + if (el == null || el.isJsonNull()) { + return null; + } + + if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) { + String raw = el.getAsString(); + if (raw != null && !raw.isBlank()) { + Identifier id = Identifier.tryParse(raw.trim()); + if (id != null && Registries.ITEM.containsId(id)) { + return id; + } + + AnimationEngine.logSetupWarning("[NoN Animation] Invalid item id '{}' for {} in {} {}.", raw, key, fileId, contextLabel); + return null; + } else { + return null; + } + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid {} in {} {}: expected item id string.", key, fileId, contextLabel); + return null; + } + } else { + return null; + } + } + + private static List readStageInfos( + JsonObject obj, Identifier defId, Identifier fileId, List actorConstraints + ) { + List stages = new ArrayList<>(); + double baseSpeed = readSpeed(obj, 1.0); + Map defaultActorProps = buildDefaultActorPropMap(actorConstraints); + Set validActorKeys = buildActorKeySet(actorConstraints); + if (obj.has("stages")) { + JsonArray stagesArr = obj.getAsJsonArray("stages"); + Map stageSpecs = new TreeMap<>(); + Map> stagePropsByNumber = new TreeMap<>(); + + for (int i = 0; i < stagesArr.size(); i++) { + JsonObject stageObj = stagesArr.get(i).getAsJsonObject(); + warnUnknownKeys(stageObj, fileId, "stage[" + i + "]", KNOWN_STAGE_KEYS); + Integer stageNumber = parseStageNumber(stageObj, fileId); + if (stageNumber != null && stageNumber > 0) { + if (stageSpecs.containsKey(stageNumber)) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: duplicate stage p{}", fileId, stageNumber); + } else { + boolean loop = readLoopFlag(stageObj); + Double cycleSeconds = readCycleSeconds(stageObj); + long lengthTicks = secondsToTicks(cycleSeconds == null ? 0.0 : cycleSeconds); + double cycleTicks = secondsToExactTicks(cycleSeconds == null ? 0.0 : cycleSeconds); + boolean allowJoin = readAllowJoin(stageObj); + double speed = readSpeed(stageObj, baseSpeed); + Integer useStage = readOptionalStageReference(stageObj, "use_stage", fileId); + double cycleMidpointOffsetSeconds = readCycleMidpointOffsetSeconds(stageObj, fileId, stageNumber, loop, cycleTicks); + if (useStage != null && useStage <= 0) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Invalid use_stage '{}' in {} stage p{}; falling back to stage itself.", useStage, fileId, stageNumber + ); + useStage = null; + } + + if (!loop && lengthTicks <= 0L) { + AnimationEngine.logSetupWarning("[NoN Animation] Stage p{} in {} is non-looping but has no cycle_seconds.", stageNumber, fileId); + } + + stageSpecs.put( + stageNumber, new AnimationDefinitions.StageSpec(loop, lengthTicks, cycleTicks, allowJoin, speed, useStage, cycleMidpointOffsetSeconds) + ); + stagePropsByNumber.put(stageNumber, resolveStageActorProps(stageObj, fileId, "p" + stageNumber, validActorKeys, defaultActorProps)); + } + } + } + + Map stageMap = new TreeMap<>(); + + for (Entry entry : stageSpecs.entrySet()) { + int stageNumber = entry.getKey(); + AnimationDefinitions.StageSpec spec = entry.getValue(); + Identifier stageId = AnimationIds.stageId(defId, stageNumber); + List stageProps = stagePropsByNumber.getOrDefault(stageNumber, List.of()); + Identifier playbackId = stageId; + if (spec.useStage != null && spec.useStage != stageNumber) { + if (stageSpecs.containsKey(spec.useStage)) { + playbackId = AnimationIds.stageId(defId, spec.useStage); + } else { + AnimationEngine.logSetupWarning( + "[NoN Animation] use_stage '{}' in {} stage p{} points to missing stage; falling back to p{}.", + spec.useStage, + fileId, + stageNumber, + stageNumber + ); + } + } + + stageMap.put( + stageNumber, + new AnimationStageInfo( + stageId, spec.loop, spec.lengthTicks, spec.allowJoin, spec.speed, playbackId, stageProps, spec.cycleMidpointOffsetSeconds, spec.cycleTicks + ) + ); + } + + stages.addAll(stageMap.values()); + } + + return List.copyOf(stages); + } + + private static Map buildDefaultActorPropMap(List actorConstraints) { + if (actorConstraints != null && !actorConstraints.isEmpty()) { + String[] actorKeys = buildUniqueActorKeys(actorConstraints); + Map defaults = new LinkedHashMap<>(); + int count = Math.min(actorConstraints.size(), actorKeys.length); + + for (int i = 0; i < count; i++) { + AnimationDefinitions.ActorConstraint constraint = actorConstraints.get(i); + Identifier left = constraint.propLeftItemId(); + Identifier right = constraint.propRightItemId(); + Identifier floor = constraint.propFloorItemId(); + if (left != null || right != null || floor != null) { + String actorKey = actorKeys[i]; + defaults.put(actorKey, new AnimationStageInfo.ActorPropEntry(actorKey, left, right, floor)); + } + } + + return defaults.isEmpty() ? Map.of() : Map.copyOf(defaults); + } else { + return Map.of(); + } + } + + private static Set buildActorKeySet(List actorConstraints) { + if (actorConstraints != null && !actorConstraints.isEmpty()) { + String[] actorKeys = buildUniqueActorKeys(actorConstraints); + LinkedHashSet out = new LinkedHashSet<>(); + Collections.addAll(out, actorKeys); + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } else { + return Set.of(); + } + } + + private static List normalizeTagList(Set tags) { + if (tags != null && !tags.isEmpty()) { + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + + for (String raw : tags) { + if (raw != null) { + String value = raw.trim().toLowerCase(Locale.ROOT); + if (!value.isEmpty() && seen.add(value)) { + out.add(value); + } + } + } + + return out.isEmpty() ? List.of() : List.copyOf(out); + } else { + return List.of(); + } + } + + private static List resolveStageActorProps( + JsonObject stageObj, Identifier fileId, String stageLabel, Set validActorKeys, Map defaultActorProps + ) { + if (stageObj == null) { + return List.of(); + } + + Map effective = new LinkedHashMap<>(); + if (defaultActorProps != null && !defaultActorProps.isEmpty()) { + effective.putAll(defaultActorProps); + } + + if (stageObj.has("props")) { + JsonElement propsEl = stageObj.get("props"); + if (propsEl == null || propsEl.isJsonNull()) { + effective.clear(); + } else if (!propsEl.isJsonObject()) { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid props in {} {}: expected object.", fileId, stageLabel); + } else { + JsonObject propsObj = propsEl.getAsJsonObject(); + + for (Entry entry : propsObj.entrySet()) { + String actorKey = entry.getKey() == null ? "" : entry.getKey().trim(); + if (actorKey.isBlank()) { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid props actor key in {} {}: blank key.", fileId, stageLabel); + } else if (validActorKeys != null && !validActorKeys.isEmpty() && !validActorKeys.contains(actorKey)) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Unknown props actor key '{}' in {} {}. Valid keys: {}", actorKey, fileId, stageLabel, validActorKeys + ); + } else { + JsonElement actorPropsEl = entry.getValue(); + if (actorPropsEl == null || actorPropsEl.isJsonNull()) { + effective.remove(actorKey); + } else if (!actorPropsEl.isJsonObject()) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Invalid props value for actor '{}' in {} {}: expected object.", actorKey, fileId, stageLabel + ); + } else { + JsonObject actorProps = actorPropsEl.getAsJsonObject(); + warnUnknownKeys(actorProps, fileId, stageLabel + ".props." + actorKey, KNOWN_STAGE_ACTOR_PROP_KEYS); + AnimationStageInfo.ActorPropEntry current = effective.get(actorKey); + Identifier left = current == null ? null : current.propLeftItemId(); + Identifier right = current == null ? null : current.propRightItemId(); + Identifier floor = current == null ? null : current.propFloorItemId(); + boolean touched = false; + if (actorProps.has("prop_left")) { + touched = true; + left = parseStagePropItemId(actorProps.get("prop_left"), fileId, stageLabel, actorKey, "prop_left", left); + } + + if (actorProps.has("prop_right")) { + touched = true; + right = parseStagePropItemId(actorProps.get("prop_right"), fileId, stageLabel, actorKey, "prop_right", right); + } + + if (actorProps.has("prop_floor")) { + touched = true; + floor = parseStagePropItemId(actorProps.get("prop_floor"), fileId, stageLabel, actorKey, "prop_floor", floor); + } + + if (touched) { + if (left == null && right == null && floor == null) { + effective.remove(actorKey); + } else { + effective.put(actorKey, new AnimationStageInfo.ActorPropEntry(actorKey, left, right, floor)); + } + } + } + } + } + } + } + + if (effective.isEmpty()) { + return List.of(); + } + + List out = new ArrayList<>(effective.values()); + out.sort(Comparator.comparing(AnimationStageInfo.ActorPropEntry::actorKey)); + return List.copyOf(out); + } + + @Nullable + private static Identifier parseStagePropItemId( + JsonElement element, Identifier fileId, String stageLabel, String actorKey, String field, @Nullable Identifier fallback + ) { + if (element == null || element.isJsonNull()) { + return null; + } + + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString(); + if (raw != null && !raw.isBlank()) { + Identifier id = Identifier.tryParse(raw.trim()); + if (id != null && Registries.ITEM.containsId(id)) { + return id; + } + + AnimationEngine.logSetupWarning("[NoN Animation] Invalid item id '{}' for {} actor '{}' in {} {}.", raw, field, actorKey, fileId, stageLabel); + return fallback; + } else { + return null; + } + } else { + AnimationEngine.logSetupWarning( + "[NoN Animation] Invalid {} for actor '{}' in {} {}: expected item id string or null.", field, actorKey, fileId, stageLabel + ); + return fallback; + } + } + + private static Integer parseStageNumber(JsonObject obj, Identifier fileId) { + if (!obj.has("stage")) { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping stage in {}: missing 'stage' number", fileId); + return null; + } + + JsonElement el = obj.get("stage"); + if (el != null && el.isJsonPrimitive()) { + JsonPrimitive prim = el.getAsJsonPrimitive(); + if (prim.isNumber()) { + return prim.getAsInt(); + } + + AnimationEngine.logSetupWarning("[NoN Animation] Skipping {}: 'stage' must be a number", fileId); + return null; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Skipping stage in {}: invalid 'stage' value", fileId); + return null; + } + } + + private static Integer readOptionalStageReference(JsonObject obj, String key, Identifier fileId) { + if (!obj.has(key)) { + return null; + } + + JsonElement el = obj.get(key); + if (el != null && el.isJsonPrimitive()) { + JsonPrimitive prim = el.getAsJsonPrimitive(); + if (prim.isNumber()) { + return prim.getAsInt(); + } + + AnimationEngine.logSetupWarning("[NoN Animation] Invalid {} in {}: value must be a number", key, fileId); + return null; + } else { + AnimationEngine.logSetupWarning("[NoN Animation] Invalid {} in {}: value is not a primitive", key, fileId); + return null; + } + } + + private static boolean readLoopFlag(JsonObject obj) { + if (!obj.has("loop")) { + return true; + } else { + JsonElement el = obj.get("loop"); + if (el != null && el.isJsonPrimitive()) { + JsonPrimitive prim = el.getAsJsonPrimitive(); + return prim.isBoolean() ? prim.getAsBoolean() : true; + } else { + return true; + } + } + } + + private static boolean readAllowJoin(JsonObject obj) { + if (!obj.has("allow_join")) { + return true; + } else { + JsonElement el = obj.get("allow_join"); + if (el != null && el.isJsonPrimitive()) { + JsonPrimitive prim = el.getAsJsonPrimitive(); + return prim.isBoolean() ? prim.getAsBoolean() : true; + } else { + return true; + } + } + } + + private static Double readCycleSeconds(JsonObject obj) { + Double seconds = readDouble(obj, "cycle_seconds"); + return seconds == null ? null : seconds; + } + + private static Double readDouble(JsonObject obj, String key) { + return !obj.has(key) ? null : readFiniteDouble(obj.get(key)); + } + + private static double readSpeed(JsonObject obj, double fallback) { + Double value = readDouble(obj, "speed"); + return value != null && Double.isFinite(value) && !(value <= 0.0) ? value : fallback; + } + + private static double readCycleMidpointOffsetSeconds(JsonObject obj, Identifier fileId, int stageNumber, boolean loop, double cycleTicks) { + if (!obj.has("cycle_midpoint_offset_seconds")) { + return 0.0; + } + + Double value = readDouble(obj, "cycle_midpoint_offset_seconds"); + if (value == null || Math.abs(value) < 1.0E-6) { + return 0.0; + } + + if (!loop) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Ignoring cycle_midpoint_offset_seconds in {} stage p{}: timing warp only applies to looping stages.", fileId, stageNumber + ); + return 0.0; + } + + if (cycleTicks <= 0.0) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Ignoring cycle_midpoint_offset_seconds in {} stage p{}: stage has no positive cycle_seconds.", fileId, stageNumber + ); + return 0.0; + } + + double clamped = StageTimeWarp.clampOffsetSeconds(cycleTicks, value); + if (Math.abs(clamped - value) > 1.0E-6) { + AnimationEngine.logSetupWarning( + "[NoN Animation] Clamped cycle_midpoint_offset_seconds in {} stage p{} from {} to {}.", fileId, stageNumber, value, clamped + ); + } + + return clamped; + } + + private static long secondsToTicks(double lengthSeconds) { + return lengthSeconds <= 0.0 ? 0L : Math.max(1L, (long)Math.ceil(lengthSeconds * 20.0)); + } + + private static double secondsToExactTicks(double lengthSeconds) { + return Double.isFinite(lengthSeconds) && !(lengthSeconds <= 0.0) ? lengthSeconds * 20.0 : 0.0; + } + + private static Integer readOptionalInt(JsonElement element) { + Double value = readFiniteDouble(element); + if (value == null) { + return null; + } else if (value < -2.1474836E9F || value > 2.147483647E9) { + return null; + } else { + return value != Math.rint(value) ? null : value.intValue(); + } + } + + private static Double readFiniteDouble(JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + return Double.isFinite(value) ? value : null; + } else { + return null; + } + } else { + return null; + } + } + + private static Identifier definitionIdFromFile(Identifier fileId) { + String path = fileId.getPath(); + String prefix = "afw_animdefs/"; + if (path.startsWith(prefix)) { + path = path.substring(prefix.length()); + } + + if (path.endsWith(".json")) { + path = path.substring(0, path.length() - 5); + } + + return Identifier.of(fileId.getNamespace(), path); + } + + public enum ActorActivity { + ACTIVE, + PASSIVE; + } + + public record ActorConstraint( + Set entityTypes, + @Nullable String entityVariant, + Set requiredTags, + Set requiredTagsAny, + String label, + AnimationDefinitions.AgeRequirement ageRequirement, + AnimationDefinitions.ActorActivity activity, + @Nullable Identifier propLeftItemId, + @Nullable Identifier propRightItemId, + @Nullable Identifier propFloorItemId + ) { + public boolean matches(Entity e) { + return this.matches(AnimationDefinitions.MatchActor.from(e)); + } + + private boolean matches(AnimationDefinitions.MatchActor actor) { + if (!this.entityTypes.isEmpty() && !this.entityTypes.contains(actor.entityType())) { + return false; + } else if (this.entityVariant != null && !this.entityVariant.equals(actor.variant())) { + return false; + } else { + Set tags = actor.tags(); + if (!this.requiredTags.isEmpty() && !tags.containsAll(this.requiredTags)) { + return false; + } else { + return !this.requiredTagsAny.isEmpty() && this.requiredTagsAny.stream().noneMatch(tags::contains) ? false : this.matchesAge(actor.baby()); + } + } + } + + public int assignmentPriority() { + return this.entityTypes.size() + this.requiredTags.size() + this.requiredTagsAny.size() + (this.entityVariant == null ? 0 : 1); + } + + public String stableKey() { + List types = this.entityTypes.stream().map(Identifier::toString).sorted().toList(); + List tagsAll = this.requiredTags.stream().sorted().toList(); + List tagsAny = this.requiredTagsAny.stream().sorted().toList(); + return "types=" + + types + + "|variant=" + + this.entityVariant + + "|tagsAll=" + + tagsAll + + "|tagsAny=" + + tagsAny + + "|age=" + + this.ageRequirement + + "|activity=" + + this.activity; + } + + public String derivedKey() { + if (this.label != null && !this.label.isBlank()) { + return AnimationDefinitions.sanitizeKeyPart(this.label); + } + + String base = "actor"; + if (!this.entityTypes.isEmpty()) { + if (this.entityTypes.size() == 1) { + base = this.entityTypes.iterator().next().getPath(); + } else { + List paths = this.entityTypes.stream().map(Identifier::getPath).sorted().toList(); + base = String.join("_", paths); + } + } + + if (!this.requiredTags.isEmpty()) { + List tags = this.requiredTags.stream().sorted().map(AnimationDefinitions::sanitizeKeyPart).toList(); + base = base + "_" + String.join("_", tags); + } + + if (!this.requiredTagsAny.isEmpty()) { + List tagsAny = this.requiredTagsAny.stream().sorted().map(AnimationDefinitions::sanitizeKeyPart).toList(); + base = base + "_any_" + String.join("_", tagsAny); + } + + if (this.entityVariant != null && !this.entityVariant.isBlank()) { + base = base + "_" + this.entityVariant; + } + + if (this.ageRequirement == AnimationDefinitions.AgeRequirement.BABY) { + base = base + "_baby"; + } + + return AnimationDefinitions.sanitizeKeyPart(base); + } + + private boolean matchesAge(Entity e) { + boolean baby = false; + if (e instanceof LivingEntity living) { + baby = living.isBaby(); + } + + return this.matchesAge(baby); + } + + private boolean matchesAge(boolean baby) { + AnimationDefinitions.AgeRequirement req = this.ageRequirement == null ? AnimationDefinitions.AgeRequirement.ADULT : this.ageRequirement; + return req == AnimationDefinitions.AgeRequirement.BABY == baby; + } + } + + private record ActorKey(Identifier entityType, boolean baby) { + } + + public enum AgeRequirement { + ADULT, + BABY; + } + + public record AnimationPackInfo( + String id, + String resourcePackId, + String name, + String author, + String version, + String description, + @Nullable Identifier localModelRoot, + List modelImports + ) { + public AnimationPackInfo { + id = AnimationDefinitions.sanitizeNonBlank(id, "unknown"); + resourcePackId = AnimationDefinitions.sanitizeNonBlank(resourcePackId, id); + name = AnimationDefinitions.sanitizeNonBlank(name, AnimationDefinitions.displayNameFromPackId(id)); + author = AnimationDefinitions.sanitizeNonBlank(author, "Unknown"); + version = AnimationDefinitions.sanitizeNullable(version); + description = AnimationDefinitions.sanitizeNullable(description); + modelImports = modelImports == null ? List.of() : List.copyOf(modelImports); + } + + public AnimationPackInfo( + String id, String resourcePackId, String name, String author, String version, String description, @Nullable Identifier localModelRoot + ) { + this(id, resourcePackId, name, author, version, description, localModelRoot, List.of()); + } + + public List modelRootsFor(@Nullable Identifier entityTypeId) { + LinkedHashSet roots = new LinkedHashSet<>(); + if (this.localModelRoot != null) { + roots.add(this.localModelRoot); + } + + if (entityTypeId != null) { + for (AnimationDefinitions.ModelImport modelImport : this.modelImports) { + if (modelImport.entityTypes().contains(entityTypeId)) { + roots.add(modelImport.modelRoot()); + if (roots.size() >= 32) { + break; + } + } + } + } + + return roots.isEmpty() ? List.of() : List.copyOf(roots); + } + } + + public record BlockPredicate(Set blockIds, Set tagIds) { + public boolean isEmpty() { + return this.blockIds.isEmpty() && this.tagIds.isEmpty(); + } + } + + public record BlockRequirements( + String type, + AnimationDefinitions.WallHeight height, + AnimationDefinitions.WallWidth width, + @Nullable AnimationDefinitions.WallFacing facing, + AnimationDefinitions.Clearance clearance, + AnimationDefinitions.CenterSupport support, + AnimationDefinitions.CenterPlacement placement, + int surfaceRadius, + AnimationDefinitions.SurfaceFootprint surfaceFootprint, + @Nullable AnimationDefinitions.BlockPredicate blocks + ) { + public boolean isWall() { + return "wall".equalsIgnoreCase(this.type); + } + + public boolean isCenterSupport() { + return "center_support".equalsIgnoreCase(this.type); + } + } + + public enum CenterPlacement { + DIRECTIONAL_CLEARANCE("directional_clearance"), + SURFACE_FOOTPRINT("surface_footprint"); + + private final String id; + + CenterPlacement(String id) { + this.id = id; + } + + public String id() { + return this.id; + } + } + + public enum CenterSupport { + FULL_BLOCK("full_block"), + SLAB("slab"), + HALF_HEIGHT("half_height"), + SURFACE("surface"); + + private final String id; + + CenterSupport(String id) { + this.id = id; + } + + public String id() { + return this.id; + } + } + + public record Clearance(int width, int height, int depth) { + } + + public record Definition( + Identifier id, + List actors, + Set requiredUnionTags, + Set animationTags, + List contentTags, + List stages, + double weight, + AnimationDefinitions.BlockRequirements blockRequirements, + AnimationDefinitions.WaterRequirement waterRequirement, + @Nullable String positionAnchorActor, + AnimationDefinitions.AnimationPackInfo packInfo, + @Nullable String displayName, + Set joinAfter, + AnimationGameplayMetadata.Data gameplayMetadata + ) { + public Definition { + joinAfter = joinAfter == null ? Set.of() : Set.copyOf(joinAfter); + gameplayMetadata = gameplayMetadata == null ? AnimationGameplayMetadata.Data.empty() : gameplayMetadata; + } + + public Definition( + Identifier id, + List actors, + Set requiredUnionTags, + Set animationTags, + List contentTags, + List stages, + double weight, + AnimationDefinitions.BlockRequirements blockRequirements, + AnimationDefinitions.WaterRequirement waterRequirement, + @Nullable String positionAnchorActor, + AnimationDefinitions.AnimationPackInfo packInfo, + @Nullable String displayName, + Set joinAfter + ) { + this( + id, + actors, + requiredUnionTags, + animationTags, + contentTags, + stages, + weight, + blockRequirements, + waterRequirement, + positionAnchorActor, + packInfo, + displayName, + joinAfter, + AnimationGameplayMetadata.Data.empty() + ); + } + + public Definition( + Identifier id, + List actors, + Set requiredUnionTags, + Set animationTags, + List contentTags, + List stages, + double weight, + AnimationDefinitions.BlockRequirements blockRequirements, + AnimationDefinitions.WaterRequirement waterRequirement, + @Nullable String positionAnchorActor, + AnimationDefinitions.AnimationPackInfo packInfo, + @Nullable String displayName + ) { + this( + id, + actors, + requiredUnionTags, + animationTags, + contentTags, + stages, + weight, + blockRequirements, + waterRequirement, + positionAnchorActor, + packInfo, + displayName, + Set.of() + ); + } + + public Definition( + Identifier id, + List actors, + Set requiredUnionTags, + Set animationTags, + List contentTags, + List stages, + double weight, + AnimationDefinitions.BlockRequirements blockRequirements, + AnimationDefinitions.WaterRequirement waterRequirement, + @Nullable String positionAnchorActor, + AnimationDefinitions.AnimationPackInfo packInfo + ) { + this( + id, actors, requiredUnionTags, animationTags, contentTags, stages, weight, blockRequirements, waterRequirement, positionAnchorActor, packInfo, null + ); + } + + public boolean matches(List selectedActorsSortedById, Set requiredAnimationTags) { + return this.matches(AnimationDefinitions.MatchContext.create(selectedActorsSortedById), requiredAnimationTags); + } + + private boolean matches(AnimationDefinitions.MatchContext context, Set requiredAnimationTags) { + return !this.matchesAnimationTags(requiredAnimationTags) ? false : this.matchesActors(context); + } + + private boolean matchesActors(AnimationDefinitions.MatchContext context) { + if (context.actors().size() != this.actors.size()) { + return false; + } else { + return !context.unionTags().containsAll(this.requiredUnionTags) + ? false + : AnimationDefinitions.matchesUnordered(context.actors(), this.actors, AnimationDefinitions.MATCH_ORDER_BY_DEFINITION.get(this.id)); + } + } + + private boolean matchesAnimationTags(Set requiredAnimationTags) { + Set required = requiredAnimationTags == null ? Set.of() : requiredAnimationTags; + if (!required.isEmpty()) { + return this.animationTags != null && !this.animationTags.isEmpty() ? this.animationTags.containsAll(required) : false; + } else { + return this.animationTags == null || this.animationTags.isEmpty() || this.animationTags.contains("normal_match"); + } + } + + public AnimationDefinitions.Definition withGameplayMetadata(AnimationGameplayMetadata.Data metadata) { + return new AnimationDefinitions.Definition( + this.id, + this.actors, + this.requiredUnionTags, + this.animationTags, + this.contentTags, + this.stages, + this.weight, + this.blockRequirements, + this.waterRequirement, + this.positionAnchorActor, + this.packInfo, + this.displayName, + this.joinAfter, + metadata + ); + } + + public boolean attackEligible() { + return this.gameplayMetadata.attackEligible(); + } + } + + private record MatchActor(Entity entity, Identifier entityType, @Nullable String variant, Set tags, boolean baby) { + private static AnimationDefinitions.MatchActor from(Entity entity) { + boolean baby = entity instanceof LivingEntity living && living.isBaby(); + return new AnimationDefinitions.MatchActor( + entity, Registries.ENTITY_TYPE.getId(entity.getType()), EntityVariants.resolveVariant(entity), Set.copyOf(NonActorEvents.resolve(entity)), baby + ); + } + } + + private record MatchContext(List actors, Set unionTags) { + private static AnimationDefinitions.MatchContext create(List entities) { + if (entities != null && !entities.isEmpty()) { + List actors = new ArrayList<>(entities.size()); + Set unionTags = new LinkedHashSet<>(); + + for (Entity entity : entities) { + if (entity != null) { + AnimationDefinitions.MatchActor actor = AnimationDefinitions.MatchActor.from(entity); + actors.add(actor); + unionTags.addAll(actor.tags()); + } + } + + return new AnimationDefinitions.MatchContext(List.copyOf(actors), Set.copyOf(unionTags)); + } else { + return new AnimationDefinitions.MatchContext(List.of(), Set.of()); + } + } + } + + public record MatchResult(List candidatesSorted, AnimationDefinitions.Definition chosen) { + } + + public record ModelImport(String packId, Identifier modelRoot, Set entityTypes) { + public ModelImport { + packId = AnimationDefinitions.sanitizeNonBlank(packId, "unknown"); + entityTypes = entityTypes == null ? Set.of() : Set.copyOf(entityTypes); + } + } + + public static final class Reloader implements SynchronousResourceReloader { + static final Identifier RELOADER_ID = AnimationEngine.runtimeId("definitions"); + + public void reload(ResourceManager manager) { + AnimationDefinitions.reload(manager); + } + } + + private record StageSpec( + boolean loop, long lengthTicks, double cycleTicks, boolean allowJoin, double speed, Integer useStage, double cycleMidpointOffsetSeconds + ) { + } + + public record SurfaceFootprint(int width, int depth, int height, int margin) { + } + + public enum WallFacing { + FRONT("front"), + BACK("back"), + LEFT("left"), + RIGHT("right"); + + private final String id; + + WallFacing(String id) { + this.id = id; + } + + public String id() { + return this.id; + } + + public boolean matches(Direction blockFacing, Direction wallSurfaceFacing) { + if (blockFacing != null && wallSurfaceFacing != null && blockFacing.getAxis() != Axis.Y && wallSurfaceFacing.getAxis() != Axis.Y) { + Direction requiredSurface = switch (this) { + case FRONT -> blockFacing; + case BACK -> blockFacing.getOpposite(); + case LEFT -> blockFacing.rotateYCounterclockwise(); + case RIGHT -> blockFacing.rotateYClockwise(); + }; + return requiredSurface == wallSurfaceFacing; + } else { + return false; + } + } + } + + public record WallHeight(int min, Integer max) { + } + + public record WallWidth(int min, Integer max) { + } + + public enum WaterRequirement { + NONE, + SURFACE, + UNDERWATER; + } +} diff --git a/src/main/java/com/nonid/internal/animation/data/AnimationGameplayMetadata.java b/src/main/java/com/nonid/internal/animation/data/AnimationGameplayMetadata.java new file mode 100644 index 0000000..5c0ef3c --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/data/AnimationGameplayMetadata.java @@ -0,0 +1,484 @@ +package com.nonid.internal.animation.data; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.nonid.NeedsOfNature; +import com.nonid.data.NonLiquidRoles; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.AnimationIds; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationGameplayMetadata { + public static final int INFINITE_STAGE_SECONDS = -1; + private static final double MIN_STAGE_DURATION_MULTIPLIER = 0.05; + private static final double MAX_STAGE_DURATION_MULTIPLIER = 20.0; + + private AnimationGameplayMetadata() { + } + + public static AnimationGameplayMetadata.Data parse(Identifier fileId, AnimationDefinitions.Definition definition, JsonObject root, List actorKeys) { + AnimationGameplayMetadata.Builder builder = new AnimationGameplayMetadata.Builder(); + builder.accept(fileId, definition, root, actorKeys); + return builder.build(); + } + + @Nullable + public static Integer peakStage(Identifier animationId) { + AnimationGameplayMetadata.Data data = data(animationId); + return data == null ? null : data.peakStage(); + } + + public static Map peakStages() { + Map out = new LinkedHashMap<>(); + + for (AnimationDefinitions.Definition definition : AnimationDefinitions.getLoadedDefinitionsSnapshot()) { + Integer peakStage = definition.gameplayMetadata().peakStage(); + if (peakStage != null) { + out.put(definition.id(), peakStage); + } + } + + return Map.copyOf(out); + } + + @Nullable + public static Integer stageSeconds(Identifier animationId) { + AnimationGameplayMetadata.Data data = data(animationId); + return data != null && animationId != null ? data.stageSeconds().get(animationId) : null; + } + + public static boolean isInfiniteStageSeconds(@Nullable Integer seconds) { + return seconds != null && seconds < 0; + } + + public static double cycleTicks(Identifier animationId) { + if (animationId == null) { + return 0.0; + } + + AnimationGameplayMetadata.Data data = data(animationId); + Double value = data == null ? null : data.cycleTicks().get(animationId); + return value != null && Double.isFinite(value) && !(value <= 0.0) ? value : 0.0; + } + + public static double stageDurationMultiplier(Identifier animationId) { + if (animationId == null) { + return 1.0; + } + + AnimationGameplayMetadata.Data data = data(animationId); + Double value = data == null ? null : data.stageDurationMultipliers().get(animationId); + return value != null && Double.isFinite(value) && !(value <= 0.0) ? value : 1.0; + } + + public static boolean escapable(Identifier animationId) { + if (animationId == null) { + return true; + } + + AnimationGameplayMetadata.Data data = data(animationId); + if (data == null) { + return true; + } + + Boolean direct = data.escapable().get(animationId); + if (direct != null) { + return direct; + } + + Boolean base = data.escapable().get(AnimationIds.baseId(animationId)); + return base == null || base; + } + + @Nullable + public static NonLiquidRoles.LiquidRoles liquidRoles(Identifier animationId) { + AnimationGameplayMetadata.Data data = data(animationId); + return data == null ? null : data.liquidRoles(); + } + + @Nullable + public static Double liquidGainMultiplier(Identifier animationId) { + AnimationGameplayMetadata.Data data = data(animationId); + return data == null ? null : data.liquidGainMultiplier(); + } + + @Nullable + public static AnimationGameplayMetadata.ManualPeakRule manualPeakRule(Identifier animationId) { + AnimationGameplayMetadata.Data data = data(animationId); + return data == null ? null : data.manualPeakRule(); + } + + @Nullable + private static AnimationGameplayMetadata.Data data(@Nullable Identifier animationId) { + Identifier baseId = AnimationIds.baseId(animationId); + AnimationDefinitions.Definition definition = AnimationDefinitions.getDefinition(baseId); + return definition == null ? null : definition.gameplayMetadata(); + } + + private static int clampStageSeconds(int value) { + return value < 0 ? -1 : Math.max(1, Math.min(300, value)); + } + + @Nullable + private static Integer readInteger(JsonElement element) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber()) { + double value = element.getAsDouble(); + return Double.isFinite(value) && value == Math.rint(value) && !(value < -2.1474836E9F) && !(value > 2.147483647E9) ? (int)value : null; + } else { + return null; + } + } + + @Nullable + private static Double readDouble(JsonElement element) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber()) { + double value = element.getAsDouble(); + return Double.isFinite(value) ? value : null; + } else { + return null; + } + } + + @Nullable + static Boolean readBoolean(JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean() ? element.getAsBoolean() : null; + } + + private static final class Builder { + private Identifier definitionId; + private final Map peakStages = new HashMap<>(); + private final Map stageSeconds = new HashMap<>(); + private final Map cycleTicks = new HashMap<>(); + private final Map stageDurationMultipliers = new HashMap<>(); + private final Map escapable = new HashMap<>(); + private final Map liquidRoles = new HashMap<>(); + private final Map liquidGainMultipliers = new HashMap<>(); + private final Map manualPeakRules = new HashMap<>(); + private boolean attackEligible = true; + + private void accept(Identifier fileId, AnimationDefinitions.Definition definition, JsonObject root, List actorKeys) { + if (definition != null && root != null) { + this.definitionId = definition.id(); + this.readAttackEligibility(fileId, root); + + try { + this.readStageMetadata(fileId, definition.id(), root); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN] Failed to read stage gameplay metadata from {}", fileId, e); + } + + try { + this.readLiquidRoles(fileId, definition, root, actorKeys); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN] Failed to read liquid roles from {}", fileId, e); + } + + try { + this.readManualPeak(fileId, definition.id(), root); + } catch (RuntimeException e) { + AnimationEngine.LOGGER.debug("[NoN] Failed to read manual peak metadata from {}", fileId, e); + } + } + } + + private void readAttackEligibility(Identifier fileId, JsonObject root) { + if (root.has("attack_eligible")) { + Boolean parsed = AnimationGameplayMetadata.readBoolean(root.get("attack_eligible")); + if (parsed == null) { + AnimationEngine.logSetupWarning("[NoN] Invalid attack_eligible in {}. Expected true or false; using default true.", fileId); + } else { + this.attackEligible = parsed; + } + } + } + + private void readStageMetadata(Identifier fileId, Identifier definitionId, JsonObject root) { + Integer baseSeconds = AnimationGameplayMetadata.readInteger(root.get("stage_seconds")); + if (baseSeconds != null) { + this.stageSeconds.put(definitionId, AnimationGameplayMetadata.clampStageSeconds(baseSeconds)); + } + + Boolean defaultEscapable = AnimationGameplayMetadata.readBoolean(root.get("escapable")); + boolean defaultEscapableValue = defaultEscapable == null || defaultEscapable; + this.escapable.put(definitionId, defaultEscapableValue); + Double liquidMultiplier = AnimationGameplayMetadata.readDouble(root.get("liquid_gain_multiplier")); + if (liquidMultiplier != null && liquidMultiplier >= 0.0) { + this.liquidGainMultipliers.put(definitionId, liquidMultiplier); + } + + if (root.has("stages") && root.get("stages").isJsonArray()) { + JsonArray stages = root.getAsJsonArray("stages"); + Integer firstPeak = null; + + for (int i = 0; i < stages.size(); i++) { + if (stages.get(i).isJsonObject()) { + JsonObject stage = stages.get(i).getAsJsonObject(); + Integer stageNumber = AnimationGameplayMetadata.readInteger(stage.get("stage")); + if (stageNumber != null && stageNumber > 0) { + Identifier stageId = AnimationIds.stageId(definitionId, stageNumber); + if (Boolean.TRUE.equals(AnimationGameplayMetadata.readBoolean(stage.get("non_peak"))) && (firstPeak == null || stageNumber < firstPeak)) { + firstPeak = stageNumber; + } + + Integer seconds = AnimationGameplayMetadata.readInteger(stage.get("stage_seconds")); + if (seconds != null) { + this.stageSeconds.put(stageId, AnimationGameplayMetadata.clampStageSeconds(seconds)); + } + + Double secondsPerCycle = AnimationGameplayMetadata.readDouble(stage.get("cycle_seconds")); + if (secondsPerCycle != null && secondsPerCycle > 0.0) { + this.cycleTicks.put(stageId, secondsPerCycle * 20.0); + } + + Boolean stageEscapable = AnimationGameplayMetadata.readBoolean(stage.get("escapable")); + this.escapable.put(stageId, stageEscapable == null ? defaultEscapableValue : stageEscapable); + if (stage.has("stage_duration_multiplier")) { + Double multiplier = AnimationGameplayMetadata.readDouble(stage.get("stage_duration_multiplier")); + Boolean loop = AnimationGameplayMetadata.readBoolean(stage.get("loop")); + if (Boolean.FALSE.equals(loop)) { + AnimationEngine.logSetupWarning( + "[NoN] Ignoring stage_duration_multiplier in {} stage {} because the stage is not looping.", definitionId, stageNumber + ); + } else if (multiplier != null && !(multiplier < 0.05) && !(multiplier > 20.0)) { + this.stageDurationMultipliers.put(stageId, multiplier); + } else { + AnimationEngine.logSetupWarning( + "[NoN] Ignoring invalid stage_duration_multiplier in {} stage {}. Expected a number from {} to {}.", + definitionId, + stageNumber, + 0.05, + 20.0 + ); + } + } + } + } + } + + if (firstPeak != null) { + this.peakStages.put(definitionId, firstPeak); + } + } + } + + private void readLiquidRoles(Identifier fileId, AnimationDefinitions.Definition definition, JsonObject root, List actorKeys) { + if (root.has("actors") && root.get("actors").isJsonArray()) { + JsonArray actors = root.getAsJsonArray("actors"); + if (!actors.isEmpty() && actorKeys != null && actorKeys.size() == actors.size()) { + Set injectorKeys = new LinkedHashSet<>(); + Set receiverKeys = new LinkedHashSet<>(); + Map injectorRoles = new LinkedHashMap<>(); + boolean explicitReceiver = false; + boolean[] injectors = new boolean[actors.size()]; + + for (int i = 0; i < actors.size(); i++) { + if (actors.get(i).isJsonObject()) { + JsonObject actor = actors.get(i).getAsJsonObject(); + String actorKey = actorKeys.get(i); + NonLiquidRoles.InjectorRole role = this.readInjectorRole(fileId, actor, i); + injectors[i] = role != NonLiquidRoles.InjectorRole.NONE; + if (role == NonLiquidRoles.InjectorRole.V || role == NonLiquidRoles.InjectorRole.A) { + injectorKeys.add(actorKey); + } + + if (injectors[i]) { + injectorRoles.put(actorKey, role); + } + + if (Boolean.TRUE.equals(AnimationGameplayMetadata.readBoolean(actor.get("receiver")))) { + receiverKeys.add(actorKey); + explicitReceiver = true; + } + } + } + + if (!explicitReceiver) { + for (int i = 0; i < actors.size(); i++) { + if (!injectors[i]) { + receiverKeys.add(actorKeys.get(i)); + } + } + } + + if (!explicitReceiver && receiverKeys.isEmpty()) { + for (int i = 0; i < definition.actors().size(); i++) { + if (definition.actors().get(i).entityTypes().contains(Identifier.of("minecraft", "player"))) { + receiverKeys.add(actorKeys.get(i)); + } + } + } + + this.liquidRoles + .put(definition.id(), new NonLiquidRoles.LiquidRoles(Set.copyOf(injectorKeys), Set.copyOf(receiverKeys), Map.copyOf(injectorRoles))); + } + } + } + + private NonLiquidRoles.InjectorRole readInjectorRole(Identifier fileId, JsonObject actor, int actorIndex) { + JsonElement element = actor.get("injector"); + if (element == null || element.isJsonNull()) { + return NonLiquidRoles.InjectorRole.NONE; + } + + if (!element.isJsonPrimitive()) { + this.warnInvalidInjector(fileId, actorIndex, "value must be true/false or V/A/M"); + return NonLiquidRoles.InjectorRole.NONE; + } + + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return primitive.getAsBoolean() ? NonLiquidRoles.InjectorRole.V : NonLiquidRoles.InjectorRole.NONE; + } + + if (!primitive.isString()) { + this.warnInvalidInjector(fileId, actorIndex, "value must be true/false or V/A/M"); + return NonLiquidRoles.InjectorRole.NONE; + } + + String raw = primitive.getAsString(); + if (raw != null && !raw.isBlank()) { + return switch (raw.trim().toUpperCase(Locale.ROOT)) { + case "V" -> NonLiquidRoles.InjectorRole.V; + case "A" -> NonLiquidRoles.InjectorRole.A; + case "M" -> NonLiquidRoles.InjectorRole.M; + default -> { + this.warnInvalidInjector(fileId, actorIndex, "unknown injector role '" + raw + "'"); + yield NonLiquidRoles.InjectorRole.NONE; + } + }; + } else { + return NonLiquidRoles.InjectorRole.NONE; + } + } + + private void warnInvalidInjector(Identifier fileId, int actorIndex, String reason) { + AnimationEngine.logSetupWarning("[NoN] Ignoring invalid injector in {} actor {}: {}. Expected true/false or V/A/M.", fileId, actorIndex, reason); + } + + private void readManualPeak(Identifier fileId, Identifier definitionId, JsonObject root) { + if (root.has("manual_peak") && root.get("manual_peak").isJsonObject()) { + JsonObject manualPeak = root.getAsJsonObject("manual_peak"); + Set items = new LinkedHashSet<>(); + this.addHeldItem(fileId, items, manualPeak.get("held_item")); + if (manualPeak.has("held_items") && manualPeak.get("held_items").isJsonArray()) { + for (JsonElement heldItem : manualPeak.getAsJsonArray("held_items")) { + this.addHeldItem(fileId, items, heldItem); + } + } + + Integer priority = AnimationGameplayMetadata.readInteger(manualPeak.get("priority")); + boolean propFromHeldItem = Boolean.TRUE.equals(AnimationGameplayMetadata.readBoolean(manualPeak.get("prop_from_held_item"))); + AnimationStageInfo.PropSlot propSlot = this.readManualPeakPropSlot(fileId, manualPeak.get("held_item_prop_slot")); + if (propSlot == null) { + propFromHeldItem = false; + propSlot = AnimationStageInfo.PropSlot.RIGHT; + } + + this.manualPeakRules + .put(definitionId, new AnimationGameplayMetadata.ManualPeakRule(items, priority == null ? 0 : priority, propFromHeldItem, propSlot)); + } + } + + @Nullable + private AnimationStageInfo.PropSlot readManualPeakPropSlot(Identifier fileId, @Nullable JsonElement element) { + if (element == null || element.isJsonNull()) { + return AnimationStageInfo.PropSlot.RIGHT; + } + + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String value = element.getAsString().trim().toLowerCase(Locale.ROOT); + + return switch (value) { + case "left" -> AnimationStageInfo.PropSlot.LEFT; + case "right" -> AnimationStageInfo.PropSlot.RIGHT; + case "floor" -> AnimationStageInfo.PropSlot.FLOOR; + default -> { + AnimationEngine.logSetupWarning( + "[NoN] Invalid manual_peak held_item_prop_slot '{}' in {}. Expected left, right, or floor; dynamic held-item prop disabled.", + value, + fileId + ); + yield null; + } + }; + } else { + AnimationEngine.logSetupWarning( + "[NoN] Invalid manual_peak held_item_prop_slot in {}. Expected left, right, or floor; dynamic held-item prop disabled.", fileId + ); + return null; + } + } + + private void addHeldItem(Identifier fileId, Set items, JsonElement element) { + if (element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + String raw = element.getAsString(); + if (raw != null && !raw.isBlank()) { + Identifier itemId = Identifier.tryParse(raw.trim()); + if (itemId != null && Registries.ITEM.containsId(itemId)) { + items.add(itemId); + } else { + NeedsOfNature.logSetupWarning("[NoN] Ignoring invalid manual peak held item '{}' in {}.", raw, fileId); + } + } + } + } + + private AnimationGameplayMetadata.Data build() { + return this.definitionId == null + ? AnimationGameplayMetadata.Data.empty() + : new AnimationGameplayMetadata.Data( + this.peakStages.get(this.definitionId), + Map.copyOf(this.stageSeconds), + Map.copyOf(this.cycleTicks), + Map.copyOf(this.stageDurationMultipliers), + Map.copyOf(this.escapable), + this.liquidRoles.get(this.definitionId), + this.liquidGainMultipliers.get(this.definitionId), + this.manualPeakRules.get(this.definitionId), + this.attackEligible + ); + } + } + + public record Data( + @Nullable Integer peakStage, + Map stageSeconds, + Map cycleTicks, + Map stageDurationMultipliers, + Map escapable, + @Nullable NonLiquidRoles.LiquidRoles liquidRoles, + @Nullable Double liquidGainMultiplier, + @Nullable AnimationGameplayMetadata.ManualPeakRule manualPeakRule, + boolean attackEligible + ) { + public Data { + stageSeconds = stageSeconds == null ? Map.of() : Map.copyOf(stageSeconds); + cycleTicks = cycleTicks == null ? Map.of() : Map.copyOf(cycleTicks); + stageDurationMultipliers = stageDurationMultipliers == null ? Map.of() : Map.copyOf(stageDurationMultipliers); + escapable = escapable == null ? Map.of() : Map.copyOf(escapable); + } + + public static AnimationGameplayMetadata.Data empty() { + return new AnimationGameplayMetadata.Data(null, Map.of(), Map.of(), Map.of(), Map.of(), null, null, null, true); + } + } + + public record ManualPeakRule(Set heldItems, int priority, boolean propFromHeldItem, AnimationStageInfo.PropSlot heldItemPropSlot) { + public ManualPeakRule { + heldItems = heldItems == null ? Set.of() : Set.copyOf(heldItems); + heldItemPropSlot = heldItemPropSlot == null ? AnimationStageInfo.PropSlot.RIGHT : heldItemPropSlot; + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/data/AnimationSoundCueParser.java b/src/main/java/com/nonid/internal/animation/data/AnimationSoundCueParser.java new file mode 100644 index 0000000..3c9f373 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/data/AnimationSoundCueParser.java @@ -0,0 +1,191 @@ +package com.nonid.internal.animation.data; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map.Entry; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationSoundCueParser { + private AnimationSoundCueParser() { + } + + public static AnimationSoundCueParser.ParsedAnimation parse(JsonObject animation, String sourceName, AnimationSoundCueParser.WarningSink warnings) { + if (animation == null) { + return new AnimationSoundCueParser.ParsedAnimation(-1.0, List.of()); + } + + double lengthSeconds = readAnimationLength(animation); + JsonElement soundElement = animation.get("sound_effects"); + if (soundElement == null) { + return new AnimationSoundCueParser.ParsedAnimation(lengthSeconds, List.of()); + } + + if (!soundElement.isJsonObject()) { + warnings.warn("[NoN Animation] Skipping {}: 'sound_effects' must be an object", sourceName); + return new AnimationSoundCueParser.ParsedAnimation(lengthSeconds, List.of()); + } + + List cues = new ArrayList<>(); + + for (Entry entry : soundElement.getAsJsonObject().entrySet()) { + String timeKey = entry.getKey(); + Double timeSeconds = parseTime(timeKey); + if (timeSeconds != null && !(timeSeconds < 0.0)) { + collectCues(entry.getValue(), timeSeconds, sourceName, warnings, cues); + } else { + warnings.warn("[NoN Animation] Skipping {}: invalid sound time '{}'", sourceName, timeKey); + } + } + + return new AnimationSoundCueParser.ParsedAnimation(lengthSeconds, List.copyOf(cues)); + } + + private static void collectCues( + JsonElement element, double timeSeconds, String sourceName, AnimationSoundCueParser.WarningSink warnings, List output + ) { + if (element != null && !element.isJsonNull() && element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + String effect = readString(object, "effect"); + if (effect != null && !effect.isBlank()) { + float volume = readFloat(object, "volume", 1.0F); + float pitch = readFloat(object, "pitch", 1.0F); + collectCue(effect, timeSeconds, sourceName, warnings, output, volume, pitch); + String sound = readString(object, "sound"); + if (sound != null && !sound.isBlank() && !sound.trim().equals(effect.trim())) { + collectCue(sound, timeSeconds, sourceName, warnings, output, volume, pitch); + } + } else { + warnMissingEffect(sourceName, timeSeconds, warnings); + } + } else { + warnings.warn("[NoN Animation] Skipping {}: sound cue at {} must be an object", sourceName, String.format(Locale.ROOT, "%.3f", timeSeconds)); + } + } + + private static void collectCue( + String effect, + double timeSeconds, + String sourceName, + AnimationSoundCueParser.WarningSink warnings, + List output, + float volume, + float pitch + ) { + String normalizedEffect = effect.trim(); + if (normalizedEffect.startsWith("random(")) { + List sounds = readInlineSoundPool(normalizedEffect, sourceName, timeSeconds, warnings); + if (sounds != null) { + output.add(new AnimationSoundCueParser.ParsedCue(timeSeconds, null, sounds, volume, pitch)); + } + } else { + output.add(new AnimationSoundCueParser.ParsedCue(timeSeconds, normalizedEffect, List.of(), volume, pitch)); + } + } + + private static void warnMissingEffect(String sourceName, double timeSeconds, AnimationSoundCueParser.WarningSink warnings) { + warnings.warn("[NoN Animation] Skipping {}: sound cue at {} missing 'effect'", sourceName, String.format(Locale.ROOT, "%.3f", timeSeconds)); + } + + @Nullable + private static List readInlineSoundPool(String effect, String sourceName, double timeSeconds, AnimationSoundCueParser.WarningSink warnings) { + String formattedTime = String.format(Locale.ROOT, "%.3f", timeSeconds); + if (!effect.endsWith(")")) { + warnings.warn("[NoN Animation] Skipping {}: malformed random sound pool at {}. Expected random(namespace:sound, ...)", sourceName, formattedTime); + return null; + } + + String contents = effect.substring("random(".length(), effect.length() - 1).trim(); + if (contents.isEmpty()) { + warnings.warn("[NoN Animation] Skipping {}: random sound pool at {} cannot be empty", sourceName, formattedTime); + return null; + } + + List sounds = new ArrayList<>(); + + for (String candidate : contents.split(",", -1)) { + String raw = candidate.trim(); + Identifier soundId = raw.indexOf(58) > 0 ? Identifier.tryParse(raw) : null; + if (soundId == null) { + warnings.warn("[NoN Animation] Skipping {}: invalid or unqualified sound id '{}' in random sound pool at {}", sourceName, raw, formattedTime); + return null; + } + + sounds.add(soundId); + } + + return List.copyOf(sounds); + } + + private static double readAnimationLength(JsonObject animation) { + Double animationLength = readFiniteDouble(animation.get("animation_length")); + if (animationLength != null) { + return animationLength; + } + + Double length = readFiniteDouble(animation.get("length")); + return length == null ? -1.0 : length; + } + + @Nullable + private static Double parseTime(String raw) { + if (raw != null && !raw.isBlank()) { + try { + double value = Double.parseDouble(raw.trim()); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException e) { + return null; + } + } else { + return null; + } + } + + @Nullable + private static String readString(JsonObject object, String key) { + JsonElement value = object.get(key); + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : null; + } + + private static float readFloat(JsonObject object, String key, float fallback) { + Double value = readFiniteDouble(object.get(key)); + return value == null ? fallback : value.floatValue(); + } + + @Nullable + private static Double readFiniteDouble(JsonElement element) { + if (element != null && element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + return Double.isFinite(value) ? value : null; + } else { + return !primitive.isString() ? null : parseTime(primitive.getAsString()); + } + } else { + return null; + } + } + + public record ParsedAnimation(double lengthSeconds, List cues) { + } + + public record ParsedCue(double timeSeconds, @Nullable String effect, List randomSounds, float volume, float pitch) { + public ParsedCue { + randomSounds = randomSounds == null ? List.of() : List.copyOf(randomSounds); + } + + public double timeTicks() { + return this.timeSeconds * 20.0; + } + } + + @FunctionalInterface + public interface WarningSink { + void warn(String var1, Object... var2); + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/EntityPushAwayMixin.java b/src/main/java/com/nonid/internal/animation/mixin/EntityPushAwayMixin.java new file mode 100644 index 0000000..cd6a5ad --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/EntityPushAwayMixin.java @@ -0,0 +1,25 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.Entity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class EntityPushAwayMixin { + @Inject(method = "pushAwayFrom", at = @At("HEAD"), cancellable = true) + private void afw$disablePushAwayDuringAnimation(Entity other, CallbackInfo ci) { + if (other != null) { + Entity self = (Entity)(Object)this; + if (self.getEntityWorld() instanceof ServerWorld world) { + if (ServerAnimationController.isActorPendingOrActive(world, self.getUuid()) + || ServerAnimationController.isActorPendingOrActive(world, other.getUuid())) { + ci.cancel(); + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/LivingEntityDamageMixin.java b/src/main/java/com/nonid/internal/animation/mixin/LivingEntityDamageMixin.java new file mode 100644 index 0000000..4c1d357 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/LivingEntityDamageMixin.java @@ -0,0 +1,28 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.NonAnimationRescueSystem; +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityDamageMixin { + @Inject(method = "damage", at = @At("HEAD"), cancellable = true) + private void afw$handleDamage(ServerWorld world, DamageSource source, float amount, CallbackInfoReturnable cir) { + LivingEntity actor = (LivingEntity)(Object)this; + MobEntity redirectTarget = NonAnimationRescueSystem.findDirectDamageRedirectTarget(world, actor, source); + if (redirectTarget != null) { + cir.setReturnValue(redirectTarget.damage(world, source, amount)); + } else { + if (!ServerAnimationController.shouldAllowActorDamage(world, actor, source)) { + cir.setReturnValue(false); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/LivingEntityPushableMixin.java b/src/main/java/com/nonid/internal/animation/mixin/LivingEntityPushableMixin.java new file mode 100644 index 0000000..2540e6f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/LivingEntityPushableMixin.java @@ -0,0 +1,22 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.LivingEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityPushableMixin { + @Inject(method = "isPushable", at = @At("HEAD"), cancellable = true) + private void afw$disablePushDuringAnimation(CallbackInfoReturnable cir) { + LivingEntity self = (LivingEntity)(Object)this; + if (self.getEntityWorld() instanceof ServerWorld world) { + if (ServerAnimationController.isActorPendingOrActive(world, self.getUuid())) { + cir.setReturnValue(false); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/MobEntityIgnoreAttackersMixin.java b/src/main/java/com/nonid/internal/animation/mixin/MobEntityIgnoreAttackersMixin.java new file mode 100644 index 0000000..75a315a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/MobEntityIgnoreAttackersMixin.java @@ -0,0 +1,23 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(MobEntity.class) +public abstract class MobEntityIgnoreAttackersMixin { + @Inject(method = "tryAttack", at = @At("HEAD"), cancellable = true) + private void afw$blockDirectAttackOnIgnoredActors(ServerWorld world, Entity target, CallbackInfoReturnable cir) { + if (target instanceof LivingEntity livingTarget) { + if (ServerAnimationController.shouldIgnoreAttackers(world, livingTarget)) { + cir.setReturnValue(false); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/ServerPlayNetworkHandlerMixin.java b/src/main/java/com/nonid/internal/animation/mixin/ServerPlayNetworkHandlerMixin.java new file mode 100644 index 0000000..88e2d28 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/ServerPlayNetworkHandlerMixin.java @@ -0,0 +1,115 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.network.packet.c2s.play.ButtonClickC2SPacket; +import net.minecraft.network.packet.c2s.play.ClickSlotC2SPacket; +import net.minecraft.network.packet.c2s.play.CreativeInventoryActionC2SPacket; +import net.minecraft.network.packet.c2s.play.PickItemFromBlockC2SPacket; +import net.minecraft.network.packet.c2s.play.PickItemFromEntityC2SPacket; +import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; +import net.minecraft.network.packet.c2s.play.PlayerInteractBlockC2SPacket; +import net.minecraft.network.packet.c2s.play.PlayerInteractEntityC2SPacket; +import net.minecraft.network.packet.c2s.play.PlayerInteractItemC2SPacket; +import net.minecraft.network.packet.c2s.play.SlotChangedStateC2SPacket; +import net.minecraft.network.packet.c2s.play.UpdateSelectedSlotC2SPacket; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerPlayNetworkHandler.class) +public abstract class ServerPlayNetworkHandlerMixin { + @Shadow + public ServerPlayerEntity player; + + @Unique + private boolean afw$shouldBlock() { + if (this.player == null) { + return false; + } else { + return this.player.getEntityWorld() instanceof ServerWorld world ? ServerAnimationController.isActorActive(world, this.player.getUuid()) : false; + } + } + + @Inject(method = "onPlayerAction", at = @At("HEAD"), cancellable = true) + private void afw$blockPlayerAction(PlayerActionC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onPlayerInteractBlock", at = @At("HEAD"), cancellable = true) + private void afw$blockInteractBlock(PlayerInteractBlockC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onPlayerInteractItem", at = @At("HEAD"), cancellable = true) + private void afw$blockInteractItem(PlayerInteractItemC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onPlayerInteractEntity", at = @At("HEAD"), cancellable = true) + private void afw$blockInteractEntity(PlayerInteractEntityC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onClickSlot", at = @At("HEAD"), cancellable = true) + private void afw$blockClickSlot(ClickSlotC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onButtonClick", at = @At("HEAD"), cancellable = true) + private void afw$blockButtonClick(ButtonClickC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onCreativeInventoryAction", at = @At("HEAD"), cancellable = true) + private void afw$blockCreative(CreativeInventoryActionC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onSlotChangedState", at = @At("HEAD"), cancellable = true) + private void afw$blockSlotState(SlotChangedStateC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onUpdateSelectedSlot", at = @At("HEAD"), cancellable = true) + private void afw$blockSelectedSlot(UpdateSelectedSlotC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onPickItemFromBlock", at = @At("HEAD"), cancellable = true) + private void afw$blockPickFromBlock(PickItemFromBlockC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "onPickItemFromEntity", at = @At("HEAD"), cancellable = true) + private void afw$blockPickFromEntity(PickItemFromEntityC2SPacket packet, CallbackInfo ci) { + if (this.afw$shouldBlock()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/TargetPredicateMixin.java b/src/main/java/com/nonid/internal/animation/mixin/TargetPredicateMixin.java new file mode 100644 index 0000000..b7c2a21 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/TargetPredicateMixin.java @@ -0,0 +1,20 @@ +package com.nonid.internal.animation.mixin; + +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.ai.TargetPredicate; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(TargetPredicate.class) +public abstract class TargetPredicateMixin { + @Inject(method = "test", at = @At("HEAD"), cancellable = true) + private void afw$ignoreAnimationActors(ServerWorld world, LivingEntity tester, LivingEntity target, CallbackInfoReturnable cir) { + if (ServerAnimationController.shouldIgnoreAttackers(world, target)) { + cir.setReturnValue(false); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/CameraAnimationMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/CameraAnimationMixin.java new file mode 100644 index 0000000..a9271e2 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/CameraAnimationMixin.java @@ -0,0 +1,461 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.camera.AnimatedCameraPoseTracker; +import com.nonid.internal.animation.client.camera.AnimationCameraZoom; +import com.nonid.internal.animation.client.camera.FirstPersonCameraCollision; +import com.nonid.internal.animation.client.camera.FirstPersonLookConstraint; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.Objects; +import java.util.UUID; +import net.minecraft.block.BlockState; +import net.minecraft.block.ShapeContext; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.Camera; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.shape.VoxelShape; +import net.minecraft.world.World; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Camera.class) +public abstract class CameraAnimationMixin { + private static final double CAMERA_FORWARD_OFFSET = 0.2; + private static final double THIRD_PERSON_ORBIT_EPSILON_SQ = 1.0E-8; + private static final long FALLBACK_POSE_HOLD_TICKS = 20L; + private static final long START_ALIGNMENT_TICKS = 8L; + private static final long THIRD_PERSON_ORBIT_SMOOTH_TICKS = 6L; + private static final long NEW_INSTANCE_ORBIT_SAMPLE_DELAY_TICKS = 4L; + private static final long BODY_ORBIT_VALIDATION_INTERVAL_TICKS = 20L; + private static final double BODY_ORBIT_MISMATCH_DISTANCE_SQ = 0.25; + private static final int BODY_ORBIT_MISMATCHES_BEFORE_CORRECTION = 2; + private static final double THIRD_PERSON_FOCUS_BOX_SIZE = 0.25; + private static final double THIRD_PERSON_FOCUS_UP_STEP = 0.1; + private static final int THIRD_PERSON_FOCUS_UP_STEPS = 16; + private static final double THIRD_PERSON_FOCUS_HORIZONTAL_STEP = 0.25; + private static final int THIRD_PERSON_FOCUS_HORIZONTAL_RINGS = 4; + private static AnimatedCameraPoseTracker.PoseSample afw$lastValidPosedPose = null; + private static Vec3d afw$lastConstrainedLook = null; + private static UUID afw$lastActiveInstanceId = null; + private static UUID afw$startAlignInstanceId = null; + private static UUID afw$bodyOrbitInstanceId = null; + private static Identifier afw$bodyOrbitAnimationId = null; + private static Vec3d afw$bodyOrbitTarget = null; + private static boolean afw$delayBodyOrbitForCurrentInstance = false; + private static long afw$nextBodyOrbitValidationTick = 0L; + private static int afw$bodyOrbitMismatchCount = 0; + private static UUID afw$smoothedOrbitInstanceId = null; + private static Identifier afw$smoothedOrbitAnimationId = null; + private static Vec3d afw$orbitSmoothStart = null; + private static Vec3d afw$orbitSmoothTarget = null; + private static Vec3d afw$currentSmoothedOrbitTarget = null; + private static long afw$orbitSmoothStartTick = 0L; + private static boolean afw$wasFirstPersonLastFrame = false; + private static boolean afw$startAlignPendingTarget = false; + private static boolean afw$startAlignActive = false; + private static float afw$startAlignFromYaw = 0.0F; + private static float afw$startAlignFromPitch = 0.0F; + private static float afw$startAlignTargetYaw = 0.0F; + private static float afw$startAlignTargetPitch = 0.0F; + private static long afw$startAlignTick = 0L; + + @Shadow + protected abstract void setPos(Vec3d var1); + + @Shadow + protected abstract void setRotation(float var1, float var2); + + @Shadow + protected abstract void moveBy(float var1, float var2, float var3); + + @Shadow + private float clipToSpace(float startingDistance) { + return startingDistance; + } + + @Inject(method = "update", at = @At("TAIL")) + private void afw$lockFirstPersonCameraToAnimatedPose( + World area, Entity focusedEntity, boolean thirdPerson, boolean inverseView, float tickDelta, CallbackInfo ci + ) { + if (!(focusedEntity instanceof PlayerEntity focusedPlayer)) { + afw$resetAnimationCameraState(false); + } else { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null && client.player != null) { + if (!focusedPlayer.getUuid().equals(client.player.getUuid())) { + afw$resetAnimationCameraState(!thirdPerson); + } else { + UUID actorUuid = focusedPlayer.getUuid(); + boolean pendingOrActive = ClientAnimationRuntime.isActorPendingOrActive(actorUuid); + UUID activeInstanceId = ClientAnimationRuntime.findLatestActiveInstanceContaining(actorUuid); + if (!pendingOrActive) { + afw$resetAnimationCameraState(!thirdPerson); + } else if (thirdPerson) { + afw$lastActiveInstanceId = activeInstanceId; + afw$wasFirstPersonLastFrame = false; + Identifier activeAnimationId = ClientAnimationRuntime.findLatestActiveAnimationIdContaining(actorUuid); + this.afw$applyThirdPersonCameraAdjustments(actorUuid, activeInstanceId, activeAnimationId, focusedPlayer, tickDelta, client.world.getTime()); + } else { + boolean enteredFirstPerson = !afw$wasFirstPersonLastFrame; + boolean instanceChanged = !Objects.equals(activeInstanceId, afw$lastActiveInstanceId); + boolean replacingActiveInstance = instanceChanged && afw$lastActiveInstanceId != null; + if (activeInstanceId == null || !enteredFirstPerson && (!instanceChanged || replacingActiveInstance)) { + if (replacingActiveInstance) { + afw$clearStartAlignment(); + } + } else { + afw$beginStartAlignment(activeInstanceId, focusedPlayer.getYaw(tickDelta), focusedPlayer.getPitch(tickDelta)); + } + + afw$lastActiveInstanceId = activeInstanceId; + afw$wasFirstPersonLastFrame = true; + long nowTick = client.world.getTime(); + AnimatedCameraPoseTracker.PoseSample posedPose = AnimatedCameraPoseTracker.resolve(actorUuid, nowTick); + if (posedPose != null) { + afw$lastValidPosedPose = posedPose; + } else { + if (afw$lastValidPosedPose == null || nowTick - afw$lastValidPosedPose.tick() > 20L) { + return; + } + + posedPose = afw$lastValidPosedPose; + } + + if (afw$startAlignPendingTarget && Objects.equals(activeInstanceId, afw$startAlignInstanceId)) { + Vec3d targetLook = afw$resolveStartAlignmentTargetLook(posedPose); + if (targetLook != null) { + afw$startAlignTargetYaw = FirstPersonLookConstraint.toMinecraftYaw(targetLook, afw$startAlignFromYaw); + afw$startAlignTargetPitch = FirstPersonLookConstraint.toMinecraftPitch(targetLook); + afw$startAlignTick = nowTick; + afw$startAlignPendingTarget = false; + afw$startAlignActive = true; + } + } + + float desiredYaw = focusedPlayer.getYaw(tickDelta); + float desiredPitch = focusedPlayer.getPitch(tickDelta); + if (afw$startAlignActive && Objects.equals(activeInstanceId, afw$startAlignInstanceId)) { + float progress = MathHelper.clamp(((float)(nowTick - afw$startAlignTick) + tickDelta) / 8.0F, 0.0F, 1.0F); + float eased = afw$smootherStep(progress); + desiredYaw = afw$lerpAngleDegrees(eased, afw$startAlignFromYaw, afw$startAlignTargetYaw); + desiredPitch = MathHelper.lerp(eased, afw$startAlignFromPitch, afw$startAlignTargetPitch); + if (progress >= 1.0F) { + afw$startAlignActive = false; + } + } + + Vec3d desiredLook = Vec3d.fromPolar(desiredPitch, desiredYaw); + Vec3d constrainedLook = FirstPersonLookConstraint.constrain(desiredLook, posedPose, afw$lastConstrainedLook); + float constrainedYaw = FirstPersonLookConstraint.toMinecraftYaw(constrainedLook, desiredYaw); + float constrainedPitch = FirstPersonLookConstraint.toMinecraftPitch(constrainedLook); + focusedPlayer.lastYaw = constrainedYaw; + focusedPlayer.lastPitch = constrainedPitch; + focusedPlayer.setYaw(constrainedYaw); + focusedPlayer.setPitch(constrainedPitch); + focusedPlayer.lastHeadYaw = constrainedYaw; + focusedPlayer.headYaw = constrainedYaw; + focusedPlayer.setHeadYaw(constrainedYaw); + focusedPlayer.lastBodyYaw = constrainedYaw; + focusedPlayer.bodyYaw = constrainedYaw; + focusedPlayer.setBodyYaw(constrainedYaw); + this.setRotation(constrainedYaw, constrainedPitch); + Vec3d desiredCameraPos = posedPose.worldPos().add(constrainedLook.multiply(0.2)); + this.setPos(FirstPersonCameraCollision.resolve(area, desiredCameraPos)); + afw$lastConstrainedLook = constrainedLook; + } + } + } else { + afw$resetAnimationCameraState(!thirdPerson); + } + } + } + + private static void afw$beginStartAlignment(UUID instanceId, float startYaw, float startPitch) { + afw$startAlignInstanceId = instanceId; + afw$startAlignPendingTarget = true; + afw$startAlignActive = false; + afw$startAlignFromYaw = startYaw; + afw$startAlignFromPitch = startPitch; + afw$startAlignTargetYaw = startYaw; + afw$startAlignTargetPitch = startPitch; + afw$startAlignTick = 0L; + } + + private static void afw$clearStartAlignment() { + afw$startAlignInstanceId = null; + afw$startAlignPendingTarget = false; + afw$startAlignActive = false; + } + + private static Vec3d afw$resolveStartAlignmentTargetLook(AnimatedCameraPoseTracker.PoseSample pose) { + Vec3d targetLook = pose.headForward(); + if (targetLook == null || targetLook.lengthSquared() < 1.0E-8 || !targetLook.isFinite()) { + targetLook = pose.bodyForward(); + } + + return targetLook != null && !(targetLook.lengthSquared() < 1.0E-8) && targetLook.isFinite() ? targetLook.normalize() : null; + } + + private void afw$applyThirdPersonCameraAdjustments( + UUID actorUuid, UUID activeInstanceId, Identifier activeAnimationId, PlayerEntity focusedPlayer, float tickDelta, long nowTick + ) { + Vec3d focusPos = focusedPlayer.getCameraPosVec(tickDelta); + if (focusPos != null) { + Vec3d adjustedFocus = focusPos; + Vec3d orbitTarget = ClientAnimationRuntime.findCameraOrbitTargetForActor(actorUuid); + if (orbitTarget != null) { + afw$clearBodyOrbitTarget(); + Vec3d horizontalDelta = new Vec3d(orbitTarget.x - focusPos.x, 0.0, orbitTarget.z - focusPos.z); + if (horizontalDelta.isFinite() && horizontalDelta.lengthSquared() >= 1.0E-8) { + adjustedFocus = focusPos.add(horizontalDelta); + } + } else { + Vec3d bodyTarget = afw$resolveBodyOrbitTarget(actorUuid, activeInstanceId, activeAnimationId, nowTick); + if (bodyTarget != null) { + adjustedFocus = afw$resolveSmoothedOrbitTarget(activeInstanceId, activeAnimationId, focusPos, bodyTarget, nowTick, tickDelta); + } else if (afw$currentSmoothedOrbitTarget != null && afw$currentSmoothedOrbitTarget.isFinite()) { + adjustedFocus = afw$currentSmoothedOrbitTarget; + } else if (activeInstanceId == null) { + afw$clearOrbitSmoothing(); + } + } + + adjustedFocus = afw$resolveClearThirdPersonFocus(focusedPlayer.getEntityWorld(), adjustedFocus); + float zoomDistance = AnimationCameraZoom.distance(tickDelta); + this.setPos(adjustedFocus); + this.moveBy(-this.clipToSpace(zoomDistance), 0.0F, 0.0F); + } + } + + private static Vec3d afw$resolveClearThirdPersonFocus(World world, Vec3d desiredFocus) { + if (world != null && desiredFocus != null && desiredFocus.isFinite()) { + if (afw$isThirdPersonFocusClear(world, desiredFocus)) { + return desiredFocus; + } + + for (int step = 1; step <= 16; step++) { + Vec3d candidate = desiredFocus.add(0.0, step * 0.1, 0.0); + if (afw$isThirdPersonFocusClear(world, candidate)) { + return candidate; + } + } + + for (int ring = 1; ring <= 4; ring++) { + double distance = ring * 0.25; + + for (int i = 0; i < 8; i++) { + double angle = (Math.PI * 2) * i / 8.0; + Vec3d candidate = desiredFocus.add(Math.cos(angle) * distance, 0.0, Math.sin(angle) * distance); + if (afw$isThirdPersonFocusClear(world, candidate)) { + return candidate; + } + } + } + + return desiredFocus; + } else { + return desiredFocus; + } + } + + private static boolean afw$isThirdPersonFocusClear(World world, Vec3d focus) { + if (world != null && focus != null && focus.isFinite()) { + Box focusBox = Box.of(focus, 0.25, 0.25, 0.25); + return world.isSpaceEmpty(focusBox) && !afw$intersectsCameraCollisionShape(world, focusBox); + } else { + return true; + } + } + + private static boolean afw$intersectsCameraCollisionShape(World world, Box focusBox) { + int minX = MathHelper.floor(focusBox.minX); + int maxX = MathHelper.floor(focusBox.maxX); + int minY = MathHelper.floor(focusBox.minY); + int maxY = MathHelper.floor(focusBox.maxY); + int minZ = MathHelper.floor(focusBox.minZ); + int maxZ = MathHelper.floor(focusBox.maxZ); + ShapeContext shapeContext = ShapeContext.absent(); + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockPos pos = new BlockPos(x, y, z); + BlockState state = world.getBlockState(pos); + if (!state.isAir()) { + VoxelShape shape = state.getCameraCollisionShape(world, pos, shapeContext); + if (!shape.isEmpty()) { + for (Box shapeBox : shape.getBoundingBoxes()) { + if (focusBox.intersects(shapeBox.offset(pos))) { + return true; + } + } + } + } + } + } + } + + return false; + } + + private static Vec3d afw$resolveBodyOrbitTarget(UUID actorUuid, UUID activeInstanceId, Identifier activeAnimationId, long nowTick) { + if (actorUuid != null && activeInstanceId != null && activeAnimationId != null) { + boolean instanceChanged = !Objects.equals(activeInstanceId, afw$bodyOrbitInstanceId); + boolean animationChanged = !Objects.equals(activeAnimationId, afw$bodyOrbitAnimationId); + if (instanceChanged || animationChanged) { + if (instanceChanged) { + afw$delayBodyOrbitForCurrentInstance = afw$currentSmoothedOrbitTarget == null; + } + + afw$bodyOrbitInstanceId = activeInstanceId; + afw$bodyOrbitAnimationId = activeAnimationId; + afw$bodyOrbitTarget = null; + afw$resetBodyOrbitValidation(); + } + + if (afw$bodyOrbitTarget != null) { + afw$validateBodyOrbitTarget(actorUuid, activeInstanceId, activeAnimationId, nowTick); + return afw$bodyOrbitTarget; + } else { + Long startTick = ClientAnimationRuntime.findStartTickForActor(actorUuid); + if (afw$delayBodyOrbitForCurrentInstance && startTick != null && nowTick - startTick < 4L) { + return null; + } else { + AnimatedCameraPoseTracker.PoseSample sample = afw$resolveMatchingBodyPose(actorUuid, activeInstanceId, activeAnimationId, nowTick); + if (sample == null) { + return null; + } else if (afw$delayBodyOrbitForCurrentInstance && startTick != null && sample.tick() - startTick < 4L) { + return null; + } else { + Vec3d bodyPos = sample.bodyWorldPos(); + if (bodyPos != null && bodyPos.isFinite()) { + afw$bodyOrbitTarget = bodyPos; + afw$nextBodyOrbitValidationTick = nowTick + 20L; + afw$bodyOrbitMismatchCount = 0; + return afw$bodyOrbitTarget; + } else { + return null; + } + } + } + } + } else { + afw$clearBodyOrbitTarget(); + return null; + } + } + + private static void afw$validateBodyOrbitTarget(UUID actorUuid, UUID activeInstanceId, Identifier activeAnimationId, long nowTick) { + if (nowTick >= afw$nextBodyOrbitValidationTick) { + afw$nextBodyOrbitValidationTick = nowTick + 20L; + AnimatedCameraPoseTracker.PoseSample sample = afw$resolveMatchingBodyPose(actorUuid, activeInstanceId, activeAnimationId, nowTick); + if (sample != null && sample.bodyWorldPos() != null && sample.bodyWorldPos().isFinite()) { + Vec3d latestBodyPos = sample.bodyWorldPos(); + if (afw$bodyOrbitTarget.squaredDistanceTo(latestBodyPos) <= 0.25) { + afw$bodyOrbitMismatchCount = 0; + } else { + afw$bodyOrbitMismatchCount++; + if (afw$bodyOrbitMismatchCount >= 2) { + afw$bodyOrbitTarget = latestBodyPos; + afw$bodyOrbitMismatchCount = 0; + } + } + } + } + } + + private static AnimatedCameraPoseTracker.PoseSample afw$resolveMatchingBodyPose( + UUID actorUuid, UUID activeInstanceId, Identifier activeAnimationId, long nowTick + ) { + AnimatedCameraPoseTracker.PoseSample sample = AnimatedCameraPoseTracker.resolve(actorUuid, nowTick); + return sample != null && Objects.equals(sample.instanceId(), activeInstanceId) && Objects.equals(sample.animationId(), activeAnimationId) ? sample : null; + } + + private static Vec3d afw$resolveSmoothedOrbitTarget( + UUID activeInstanceId, Identifier activeAnimationId, Vec3d fallbackStart, Vec3d desiredTarget, long nowTick, float tickDelta + ) { + if (desiredTarget != null && desiredTarget.isFinite()) { + boolean targetChanged = afw$orbitSmoothTarget == null || afw$orbitSmoothTarget.squaredDistanceTo(desiredTarget) >= 1.0E-6; + if (!Objects.equals(activeInstanceId, afw$smoothedOrbitInstanceId) + || !Objects.equals(activeAnimationId, afw$smoothedOrbitAnimationId) + || targetChanged) { + afw$smoothedOrbitInstanceId = activeInstanceId; + afw$smoothedOrbitAnimationId = activeAnimationId; + afw$orbitSmoothStart = afw$currentSmoothedOrbitTarget != null ? afw$currentSmoothedOrbitTarget : fallbackStart; + afw$orbitSmoothTarget = desiredTarget; + afw$orbitSmoothStartTick = nowTick; + } + + if (afw$orbitSmoothStart != null && afw$orbitSmoothTarget != null) { + float progress = MathHelper.clamp(((float)(nowTick - afw$orbitSmoothStartTick) + tickDelta) / 6.0F, 0.0F, 1.0F); + float eased = afw$smootherStep(progress); + afw$currentSmoothedOrbitTarget = new Vec3d( + MathHelper.lerp(eased, afw$orbitSmoothStart.x, afw$orbitSmoothTarget.x), + MathHelper.lerp(eased, afw$orbitSmoothStart.y, afw$orbitSmoothTarget.y), + MathHelper.lerp(eased, afw$orbitSmoothStart.z, afw$orbitSmoothTarget.z) + ); + if (progress >= 1.0F) { + afw$orbitSmoothStart = afw$orbitSmoothTarget; + } + + return afw$currentSmoothedOrbitTarget; + } else { + afw$currentSmoothedOrbitTarget = desiredTarget; + return desiredTarget; + } + } else { + return fallbackStart; + } + } + + private static void afw$clearBodyOrbitTarget() { + afw$bodyOrbitInstanceId = null; + afw$bodyOrbitAnimationId = null; + afw$bodyOrbitTarget = null; + afw$delayBodyOrbitForCurrentInstance = false; + afw$resetBodyOrbitValidation(); + } + + private static void afw$resetBodyOrbitValidation() { + afw$nextBodyOrbitValidationTick = 0L; + afw$bodyOrbitMismatchCount = 0; + } + + private static void afw$clearOrbitSmoothing() { + afw$smoothedOrbitInstanceId = null; + afw$smoothedOrbitAnimationId = null; + afw$orbitSmoothStart = null; + afw$orbitSmoothTarget = null; + afw$currentSmoothedOrbitTarget = null; + afw$orbitSmoothStartTick = 0L; + } + + private static float afw$lerpAngleDegrees(float delta, float start, float end) { + return start + MathHelper.wrapDegrees(end - start) * delta; + } + + private static float afw$smootherStep(float value) { + float clamped = MathHelper.clamp(value, 0.0F, 1.0F); + return clamped * clamped * clamped * (clamped * (clamped * 6.0F - 15.0F) + 10.0F); + } + + private static void afw$resetAnimationCameraState(boolean firstPerson) { + afw$lastValidPosedPose = null; + afw$lastConstrainedLook = null; + afw$lastActiveInstanceId = null; + afw$startAlignInstanceId = null; + afw$startAlignPendingTarget = false; + afw$startAlignActive = false; + afw$clearBodyOrbitTarget(); + afw$clearOrbitSmoothing(); + afw$wasFirstPersonLastFrame = firstPerson; + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/CameraPositionAccessMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/CameraPositionAccessMixin.java new file mode 100644 index 0000000..83b0398 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/CameraPositionAccessMixin.java @@ -0,0 +1,20 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.camera.CameraPositionAccess; +import net.minecraft.client.render.Camera; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(Camera.class) +public abstract class CameraPositionAccessMixin implements CameraPositionAccess { + @Shadow + private Vec3d pos; + + @Unique + @Override + public Vec3d afw$getPos() { + return this.pos; + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerInteractionManagerMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerInteractionManagerMixin.java new file mode 100644 index 0000000..f044866 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerInteractionManagerMixin.java @@ -0,0 +1,152 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.network.ClientPlayerInteractionManager; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.recipe.NetworkRecipeId; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.EntityHitResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ClientPlayerInteractionManager.class) +public abstract class ClientPlayerInteractionManagerMixin { + @Unique + private static boolean shouldBlock() { + MinecraftClient client = MinecraftClient.getInstance(); + ClientPlayerEntity player = client == null ? null : client.player; + return player == null ? false : ClientAnimationRuntime.isActorActive(player.getUuid()); + } + + @Inject(method = "breakBlock", at = @At("HEAD"), cancellable = true) + private void afw$blockBreak(BlockPos pos, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "attackBlock", at = @At("HEAD"), cancellable = true) + private void afw$blockAttack(BlockPos pos, Direction direction, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "updateBlockBreakingProgress", at = @At("HEAD"), cancellable = true) + private void afw$blockBreakProgress(BlockPos pos, Direction direction, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "interactBlock", at = @At("HEAD"), cancellable = true) + private void afw$interactBlock(ClientPlayerEntity player, Hand hand, BlockHitResult hit, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(ActionResult.FAIL); + } + } + + @Inject(method = "interactItem", at = @At("HEAD"), cancellable = true) + private void afw$interactItem(PlayerEntity player, Hand hand, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(ActionResult.FAIL); + } + } + + @Inject(method = "interactEntity", at = @At("HEAD"), cancellable = true) + private void afw$interactEntity(PlayerEntity player, Entity entity, Hand hand, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(ActionResult.FAIL); + } + } + + @Inject(method = "interactEntityAtLocation", at = @At("HEAD"), cancellable = true) + private void afw$interactEntityAt(PlayerEntity player, Entity entity, EntityHitResult hit, Hand hand, CallbackInfoReturnable cir) { + if (shouldBlock()) { + cir.setReturnValue(ActionResult.FAIL); + } + } + + @Inject(method = "attackEntity", at = @At("HEAD"), cancellable = true) + private void afw$attackEntity(PlayerEntity player, Entity target, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "clickSlot", at = @At("HEAD"), cancellable = true) + private void afw$clickSlot(int syncId, int slotId, int button, SlotActionType actionType, PlayerEntity player, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "clickRecipe", at = @At("HEAD"), cancellable = true) + private void afw$clickRecipe(int syncId, NetworkRecipeId recipeId, boolean craftAll, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "clickButton", at = @At("HEAD"), cancellable = true) + private void afw$clickButton(int syncId, int buttonId, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "clickCreativeStack", at = @At("HEAD"), cancellable = true) + private void afw$clickCreative(ItemStack stack, int slotId, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "dropCreativeStack", at = @At("HEAD"), cancellable = true) + private void afw$dropCreative(ItemStack stack, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "stopUsingItem", at = @At("HEAD"), cancellable = true) + private void afw$stopUsingItem(PlayerEntity player, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "pickItemFromBlock", at = @At("HEAD"), cancellable = true) + private void afw$pickItemFromBlock(BlockPos pos, boolean includeData, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "pickItemFromEntity", at = @At("HEAD"), cancellable = true) + private void afw$pickItemFromEntity(Entity entity, boolean includeData, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } + + @Inject(method = "slotChangedState", at = @At("HEAD"), cancellable = true) + private void afw$slotChangedState(int syncId, int revision, boolean newState, CallbackInfo ci) { + if (shouldBlock()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerMovementMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerMovementMixin.java new file mode 100644 index 0000000..3e92c4b --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/ClientPlayerMovementMixin.java @@ -0,0 +1,34 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.input.Input; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.util.PlayerInput; +import net.minecraft.util.math.Vec2f; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ClientPlayerEntity.class) +public abstract class ClientPlayerMovementMixin { + @Inject(method = "tickMovementInput", at = @At("HEAD"), cancellable = true) + private void afw$lockMovementWhileAnimating(CallbackInfo ci) { + ClientPlayerEntity self = (ClientPlayerEntity)(Object)this; + if (ClientAnimationRuntime.isActorPendingOrActive(self.getUuid())) { + self.sidewaysSpeed = 0.0F; + self.forwardSpeed = 0.0F; + self.setJumping(false); + self.setSprinting(false); + Input input = self.input; + if (input != null) { + input.playerInput = new PlayerInput(false, false, false, false, false, false, false); + if (input instanceof InputAccessor accessor) { + accessor.afw$setMovementVector(Vec2f.ZERO); + } + } + + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/ClientWorldAnimatedPlayerParticleMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/ClientWorldAnimatedPlayerParticleMixin.java new file mode 100644 index 0000000..d1aca39 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/ClientWorldAnimatedPlayerParticleMixin.java @@ -0,0 +1,53 @@ +package com.nonid.internal.animation.mixin.client; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.particle.ParticleEffect; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ClientWorld.class) +public abstract class ClientWorldAnimatedPlayerParticleMixin { + @Unique + private int afw$animatedPlayerParticleSuppressionDepth; + + @WrapOperation(method = "tickEntity", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/Entity;tick()V")) + private void afw$trackAnimatedPlayerTick(Entity entity, Operation original) { + if (entity instanceof PlayerEntity && ClientAnimationRuntime.isActorActive(entity.getUuid())) { + this.afw$animatedPlayerParticleSuppressionDepth++; + + try { + original.call(new Object[]{entity}); + } finally { + this.afw$animatedPlayerParticleSuppressionDepth--; + } + } else { + original.call(new Object[]{entity}); + } + } + + @Inject(method = "addParticle(Lnet/minecraft/particle/ParticleEffect;ZZDDDDDD)V", at = @At("HEAD"), cancellable = true) + private void afw$suppressAnimatedPlayerParticles( + ParticleEffect particle, + boolean force, + boolean important, + double x, + double y, + double z, + double velocityX, + double velocityY, + double velocityZ, + CallbackInfo ci + ) { + if (this.afw$animatedPlayerParticleSuppressionDepth > 0) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/CuboidGeoBoneHiddenCubeMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/CuboidGeoBoneHiddenCubeMixin.java new file mode 100644 index 0000000..94b0b23 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/CuboidGeoBoneHiddenCubeMixin.java @@ -0,0 +1,59 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.render.gecko.GeckoRenderTickets; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.util.math.MatrixStack; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import software.bernie.geckolib.cache.model.GeoBone; +import software.bernie.geckolib.cache.model.cuboid.CuboidGeoBone; +import software.bernie.geckolib.cache.model.cuboid.GeoCube; +import software.bernie.geckolib.renderer.base.GeoRenderState; +import software.bernie.geckolib.renderer.base.RenderPassInfo; + +@Mixin(value = CuboidGeoBone.class, remap = false) +public abstract class CuboidGeoBoneHiddenCubeMixin { + @Shadow + @Final + public GeoCube[] cubes; + + @Inject(method = "render", at = @At("HEAD"), cancellable = true) + private void afw$skipHiddenCubeIndices( + RenderPassInfo renderPassInfo, + MatrixStack poseStack, + VertexConsumer vertexConsumer, + int packedLight, + int packedOverlay, + int renderColor, + CallbackInfo ci + ) { + if (renderPassInfo != null && renderPassInfo.renderState() != null) { + Map> hiddenIndicesByBone = (Map>)renderPassInfo.renderState() + .getOrDefaultGeckolibData(GeckoRenderTickets.HIDDEN_BONE_CUBE_INDICES, Map.of()); + if (!hiddenIndicesByBone.isEmpty()) { + GeoBone bone = (GeoBone)(Object)this; + Set hiddenIndices = hiddenIndicesByBone.get(bone.name()); + if (hiddenIndices != null && !hiddenIndices.isEmpty()) { + if (bone.frameSnapshot == null || !bone.frameSnapshot.isHidden()) { + for (int i = 0; i < this.cubes.length; i++) { + GeoCube cube = this.cubes[i]; + if (cube != null && !hiddenIndices.contains(i)) { + poseStack.push(); + cube.render(poseStack, vertexConsumer, packedLight, packedOverlay, renderColor); + poseStack.pop(); + } + } + } + + ci.cancel(); + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityAnimationParticlePositionMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityAnimationParticlePositionMixin.java new file mode 100644 index 0000000..0afa71a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityAnimationParticlePositionMixin.java @@ -0,0 +1,49 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.render.gecko.ParticleKeyframes; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.UUID; +import net.minecraft.entity.Entity; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Entity.class) +public abstract class EntityAnimationParticlePositionMixin { + @Inject(method = "getBodyX", at = @At("RETURN"), cancellable = true) + private void afw$moveBodyParticleX(double widthScale, CallbackInfoReturnable cir) { + Vec3d offset = this.afw$getAnimatedParticleOffset(); + if (offset != null) { + cir.setReturnValue((Double)cir.getReturnValue() + offset.x); + } + } + + @Inject(method = "getBodyY", at = @At("RETURN"), cancellable = true) + private void afw$moveBodyParticleY(double heightScale, CallbackInfoReturnable cir) { + Vec3d offset = this.afw$getAnimatedParticleOffset(); + if (offset != null) { + cir.setReturnValue((Double)cir.getReturnValue() + offset.y); + } + } + + @Inject(method = "getBodyZ", at = @At("RETURN"), cancellable = true) + private void afw$moveBodyParticleZ(double widthScale, CallbackInfoReturnable cir) { + Vec3d offset = this.afw$getAnimatedParticleOffset(); + if (offset != null) { + cir.setReturnValue((Double)cir.getReturnValue() + offset.z); + } + } + + private Vec3d afw$getAnimatedParticleOffset() { + Entity entity = (Entity)(Object)this; + UUID actorUuid = entity.getUuid(); + if (!ClientAnimationRuntime.isActorActive(actorUuid)) { + return null; + } + + Vec3d animatedOrigin = ParticleKeyframes.findActorOrigin(actorUuid); + return animatedOrigin == null ? null : animatedOrigin.subtract(entity.getEntityPos()); + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityPushAwayMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityPushAwayMixin.java new file mode 100644 index 0000000..1ed5301 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityPushAwayMixin.java @@ -0,0 +1,23 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class EntityPushAwayMixin { + @Inject(method = "pushAwayFrom", at = @At("HEAD"), cancellable = true) + private void afw$disablePushAwayDuringAnimation(Entity other, CallbackInfo ci) { + if (other != null) { + Entity self = (Entity)(Object)this; + if (self.getEntityWorld().isClient()) { + if (ClientAnimationRuntime.isActorPendingOrActive(self.getUuid()) || ClientAnimationRuntime.isActorPendingOrActive(other.getUuid())) { + ci.cancel(); + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerAccessor.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerAccessor.java new file mode 100644 index 0000000..132a22a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerAccessor.java @@ -0,0 +1,18 @@ +package com.nonid.internal.animation.mixin.client; + +import net.minecraft.client.render.entity.EntityRenderManager; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.equipment.EquipmentModelLoader; +import net.minecraft.client.render.entity.state.EntityRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(EntityRenderManager.class) +public interface EntityRenderManagerAccessor { + @Accessor("equipmentModelLoader") + EquipmentModelLoader afw$getEquipmentModelLoader(); + + @Invoker("getRenderer") + EntityRenderer afw$getRenderer(EntityRenderState var1); +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerMixin.java new file mode 100644 index 0000000..a9daf27 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderManagerMixin.java @@ -0,0 +1,73 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.render.gecko.GeckoReplacedRender; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.UUID; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.EntityRenderManager; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.client.render.state.CameraRenderState; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.registry.Registries; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(EntityRenderManager.class) +public class EntityRenderManagerMixin { + @Inject(method = "getAndUpdateRenderState", at = @At("RETURN")) + private void afw$attachIdentity(Entity entity, float tickDelta, CallbackInfoReturnable cir) { + EntityRenderState state = (EntityRenderState)cir.getReturnValue(); + if (state instanceof AnimationRenderStateAccess access) { + access.afw$setEntityUuid(entity.getUuid()); + access.afw$setEntityTypeId(Registries.ENTITY_TYPE.getId(entity.getType())); + } + + if (state instanceof LivingEntityRenderState livingState) { + if (entity instanceof LivingEntity living) { + if (ClientAnimationRuntime.hasActiveInstances()) { + if (ClientAnimationRuntime.isActorActive(entity.getUuid())) { + UUID actorUuid = entity.getUuid(); + UUID anchorUuid = ClientAnimationRuntime.findAnchorUuidForActor(actorUuid); + boolean keepShadow = anchorUuid == null || anchorUuid.equals(actorUuid); + if (!keepShadow) { + state.shadowRadius = 0.0F; + state.shadowPieces.clear(); + } + + GeckoReplacedRender.prepare(living, livingState, tickDelta); + } + } + } + } + } + + @Redirect( + method = "render", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/render/entity/EntityRenderer;render(Lnet/minecraft/client/render/entity/state/EntityRenderState;Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/command/OrderedRenderCommandQueue;Lnet/minecraft/client/render/state/CameraRenderState;)V" + ) + ) + private void afw$swapRenderer( + EntityRenderer vanillaRenderer, EntityRenderState state, MatrixStack matrices, OrderedRenderCommandQueue queue, CameraRenderState camera + ) { + if (state instanceof LivingEntityRenderState livingState && ClientAnimationRuntime.hasActiveInstances()) { + AnimationRenderStateAccess access = (AnimationRenderStateAccess)state; + UUID uuid = access.afw$getEntityUuid(); + if (uuid != null && ClientAnimationRuntime.isActorActive(uuid) && GeckoReplacedRender.render(livingState, matrices, queue, camera)) { + state.onFire = false; + return; + } + } + + ((EntityRendererInvoker)vanillaRenderer).afw$render(state, matrices, queue, camera); + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderStateMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderStateMixin.java new file mode 100644 index 0000000..c5ee8cf --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRenderStateMixin.java @@ -0,0 +1,36 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import java.util.UUID; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(EntityRenderState.class) +public class EntityRenderStateMixin implements AnimationRenderStateAccess { + @Unique + private UUID afw$entityUuid; + @Unique + private Identifier afw$entityTypeId; + + @Override + public UUID afw$getEntityUuid() { + return this.afw$entityUuid; + } + + @Override + public void afw$setEntityUuid(UUID uuid) { + this.afw$entityUuid = uuid; + } + + @Override + public Identifier afw$getEntityTypeId() { + return this.afw$entityTypeId; + } + + @Override + public void afw$setEntityTypeId(Identifier id) { + this.afw$entityTypeId = id; + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererAnimationNameTagMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererAnimationNameTagMixin.java new file mode 100644 index 0000000..3738828 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererAnimationNameTagMixin.java @@ -0,0 +1,42 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.UUID; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(value = EntityRenderer.class, priority = 1500) +public abstract class EntityRendererAnimationNameTagMixin { + @Inject(method = "hasLabel", at = @At("HEAD"), cancellable = true) + private void afw$hideSameAnimationNameLabel(Entity entity, double distance, CallbackInfoReturnable cir) { + if (entity != null) { + UUID viewerUuid = afw$getLocalPlayerUuid(); + if (ClientAnimationRuntime.shouldHideNameLabelForViewer(viewerUuid, entity.getUuid())) { + cir.setReturnValue(false); + } + } + } + + @Inject(method = "updateRenderState", at = @At("TAIL")) + private void afw$offsetOutsideViewerAnimationNameLabel(Entity entity, EntityRenderState state, float tickDelta, CallbackInfo ci) { + if (entity != null && state != null && state.nameLabelPos != null) { + UUID viewerUuid = afw$getLocalPlayerUuid(); + double yOffset = ClientAnimationRuntime.findOutsideViewerNameLabelYOffset(viewerUuid, entity.getUuid()); + if (!(yOffset <= 0.0)) { + state.nameLabelPos = state.nameLabelPos.add(0.0, yOffset, 0.0); + } + } + } + + private static UUID afw$getLocalPlayerUuid() { + MinecraftClient client = MinecraftClient.getInstance(); + return client != null && client.player != null ? client.player.getUuid() : null; + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererInvoker.java b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererInvoker.java new file mode 100644 index 0000000..166c2fa --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/EntityRendererInvoker.java @@ -0,0 +1,15 @@ +package com.nonid.internal.animation.mixin.client; + +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.client.render.state.CameraRenderState; +import net.minecraft.client.util.math.MatrixStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(EntityRenderer.class) +public interface EntityRendererInvoker { + @Invoker("render") + void afw$render(EntityRenderState var1, MatrixStack var2, OrderedRenderCommandQueue var3, CameraRenderState var4); +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/HeldItemRendererMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/HeldItemRendererMixin.java new file mode 100644 index 0000000..5986e82 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/HeldItemRendererMixin.java @@ -0,0 +1,27 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.render.command.OrderedRenderCommandQueue; +import net.minecraft.client.render.item.HeldItemRenderer; +import net.minecraft.client.util.math.MatrixStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(HeldItemRenderer.class) +public abstract class HeldItemRendererMixin { + @Inject( + method = "renderItem(FLnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/command/OrderedRenderCommandQueue;Lnet/minecraft/client/network/ClientPlayerEntity;I)V", + at = @At("HEAD"), + cancellable = true + ) + private void afw$hideFirstPersonHandsWhileAnimating( + float tickProgress, MatrixStack matrices, OrderedRenderCommandQueue commandQueue, ClientPlayerEntity player, int light, CallbackInfo ci + ) { + if (player != null && ClientAnimationRuntime.isActorPendingOrActive(player.getUuid())) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/InputAccessor.java b/src/main/java/com/nonid/internal/animation/mixin/client/InputAccessor.java new file mode 100644 index 0000000..2a88c55 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/InputAccessor.java @@ -0,0 +1,12 @@ +package com.nonid.internal.animation.mixin.client; + +import net.minecraft.client.input.Input; +import net.minecraft.util.math.Vec2f; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Input.class) +public interface InputAccessor { + @Accessor("movementVector") + void afw$setMovementVector(Vec2f var1); +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/KeyboardInputMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/KeyboardInputMixin.java new file mode 100644 index 0000000..361005c --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/KeyboardInputMixin.java @@ -0,0 +1,31 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.input.Input; +import net.minecraft.client.input.KeyboardInput; +import net.minecraft.util.PlayerInput; +import net.minecraft.util.math.Vec2f; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(KeyboardInput.class) +public abstract class KeyboardInputMixin { + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void afw$lockKeyboardInput(CallbackInfo ci) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + if (ClientAnimationRuntime.isActorPendingOrActive(client.player.getUuid())) { + Input self = (Input)(Object)this; + self.playerInput = new PlayerInput(false, false, false, false, false, false, false); + if (self instanceof InputAccessor accessor) { + accessor.afw$setMovementVector(Vec2f.ZERO); + } + + ci.cancel(); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityJumpMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityJumpMixin.java new file mode 100644 index 0000000..8bed2b7 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityJumpMixin.java @@ -0,0 +1,20 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityJumpMixin { + @Inject(method = "jump", at = @At("HEAD"), cancellable = true) + private void afw$blockJumpWhileAnimating(CallbackInfo ci) { + LivingEntity self = (LivingEntity)(Object)this; + if (self instanceof ClientPlayerEntity player && ClientAnimationRuntime.isActorPendingOrActive(player.getUuid())) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityPushableMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityPushableMixin.java new file mode 100644 index 0000000..e11b37f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityPushableMixin.java @@ -0,0 +1,21 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityPushableMixin { + @Inject(method = "isPushable", at = @At("HEAD"), cancellable = true) + private void afw$disablePushDuringAnimation(CallbackInfoReturnable cir) { + LivingEntity self = (LivingEntity)(Object)this; + if (self.getEntityWorld().isClient()) { + if (ClientAnimationRuntime.isActorPendingOrActive(self.getUuid())) { + cir.setReturnValue(false); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererAnimationNameTagMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererAnimationNameTagMixin.java new file mode 100644 index 0000000..e376b93 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererAnimationNameTagMixin.java @@ -0,0 +1,29 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import java.util.UUID; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(value = LivingEntityRenderer.class, priority = 1500) +public abstract class LivingEntityRendererAnimationNameTagMixin { + @Inject(method = "hasLabel(Lnet/minecraft/entity/LivingEntity;D)Z", at = @At("HEAD"), cancellable = true) + private void afw$hideSameAnimationNameLabel(LivingEntity entity, double distance, CallbackInfoReturnable cir) { + if (entity != null) { + UUID viewerUuid = afw$getLocalPlayerUuid(); + if (ClientAnimationRuntime.shouldHideNameLabelForViewer(viewerUuid, entity.getUuid())) { + cir.setReturnValue(false); + } + } + } + + private static UUID afw$getLocalPlayerUuid() { + MinecraftClient client = MinecraftClient.getInstance(); + return client != null && client.player != null ? client.player.getUuid() : null; + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererMixin.java new file mode 100644 index 0000000..0ae8c33 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/LivingEntityRendererMixin.java @@ -0,0 +1,34 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.render.AnimationRenderStateAccess; +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.entity.LivingEntity; +import net.minecraft.registry.Registries; +import net.minecraft.util.math.MathHelper; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntityRenderer.class) +public abstract class LivingEntityRendererMixin { + @Inject( + method = "updateRenderState(Lnet/minecraft/entity/LivingEntity;Lnet/minecraft/client/render/entity/state/LivingEntityRenderState;F)V", + at = @At("TAIL") + ) + private void afw$attachUuidAndType(T entity, S state, float tickDelta, CallbackInfo ci) { + AnimationRenderStateAccess access = (AnimationRenderStateAccess)state; + access.afw$setEntityUuid(entity.getUuid()); + access.afw$setEntityTypeId(Registries.ENTITY_TYPE.getId(entity.getType())); + if (ClientAnimationRuntime.hasActiveInstances()) { + ClientAnimationRuntime.LockedOrientation locked = ClientAnimationRuntime.getLockedOrientation(entity.getUuid()); + if (locked != null) { + state.bodyYaw = locked.bodyYaw(); + state.relativeHeadYaw = MathHelper.wrapDegrees(locked.headYaw() - locked.bodyYaw()); + state.pitch = locked.pitch(); + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/MinecraftClientMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/MinecraftClientMixin.java new file mode 100644 index 0000000..eee3c03 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/MinecraftClientMixin.java @@ -0,0 +1,25 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.ingame.HandledScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(MinecraftClient.class) +public abstract class MinecraftClientMixin { + @Inject(method = "setScreen", at = @At("HEAD"), cancellable = true) + private void afw$blockHandledScreens(Screen screen, CallbackInfo ci) { + if (screen instanceof HandledScreen) { + MinecraftClient client = (MinecraftClient)(Object)this; + if (client.player != null) { + if (ClientAnimationRuntime.isActorPendingOrActive(client.player.getUuid())) { + ci.cancel(); + } + } + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/MouseScrollAnimationZoomMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/MouseScrollAnimationZoomMixin.java new file mode 100644 index 0000000..c453577 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/MouseScrollAnimationZoomMixin.java @@ -0,0 +1,25 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.camera.AnimationCameraZoom; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.Mouse; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Mouse.class) +public abstract class MouseScrollAnimationZoomMixin { + @Shadow + @Final + private MinecraftClient client; + + @Inject(method = "onMouseScroll", at = @At("HEAD"), cancellable = true) + private void afw$zoomAnimationCamera(long window, double horizontal, double vertical, CallbackInfo ci) { + if (AnimationCameraZoom.handleScroll(this.client, vertical)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/mixin/client/WorldRendererFirstPersonActorMixin.java b/src/main/java/com/nonid/internal/animation/mixin/client/WorldRendererFirstPersonActorMixin.java new file mode 100644 index 0000000..1f0baa2 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/mixin/client/WorldRendererFirstPersonActorMixin.java @@ -0,0 +1,32 @@ +package com.nonid.internal.animation.mixin.client; + +import com.nonid.internal.animation.client.runtime.ClientAnimationRuntime; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.Camera; +import net.minecraft.client.render.WorldRenderer; +import net.minecraft.entity.player.PlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(WorldRenderer.class) +public class WorldRendererFirstPersonActorMixin { + @Redirect(method = "fillEntityRenderStates", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/Camera;isThirdPerson()Z")) + private boolean afw$keepFocusedActorRenderableInFirstPerson(Camera camera) { + boolean thirdPerson = camera.isThirdPerson(); + if (thirdPerson) { + return true; + } + + if (camera.getFocusedEntity() instanceof PlayerEntity player) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + return !player.getUuid().equals(client.player.getUuid()) ? false : ClientAnimationRuntime.isActorPendingOrActive(player.getUuid()); + } else { + return false; + } + } else { + return false; + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/AdjustAnimationSpeedC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/AdjustAnimationSpeedC2SPayload.java new file mode 100644 index 0000000..8dcf6fd --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/AdjustAnimationSpeedC2SPayload.java @@ -0,0 +1,27 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record AdjustAnimationSpeedC2SPayload(UUID instanceId, double multiplier) implements CustomPayload { + public static final Identifier ID_RAW = AnimationEngine.runtimeId("adjust_animation_speed"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + AdjustAnimationSpeedC2SPayload::instanceId, + PacketCodecs.DOUBLE, + AdjustAnimationSpeedC2SPayload::multiplier, + AdjustAnimationSpeedC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/AdvanceAnimationStageS2CPayload.java b/src/main/java/com/nonid/internal/animation/network/AdvanceAnimationStageS2CPayload.java new file mode 100644 index 0000000..257d780 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/AdvanceAnimationStageS2CPayload.java @@ -0,0 +1,31 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record AdvanceAnimationStageS2CPayload(UUID instanceId, long advanceTick, long stageEndTick, int stageIndex) implements CustomPayload { + public static final Identifier ADVANCE_STAGE_ID = AnimationEngine.runtimeId("advance_stage"); + public static final Id ID = new Id(ADVANCE_STAGE_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + AdvanceAnimationStageS2CPayload::instanceId, + PacketCodecs.LONG, + AdvanceAnimationStageS2CPayload::advanceTick, + PacketCodecs.LONG, + AdvanceAnimationStageS2CPayload::stageEndTick, + PacketCodecs.INTEGER, + AdvanceAnimationStageS2CPayload::stageIndex, + AdvanceAnimationStageS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/AnimationSpeedUpdateS2CPayload.java b/src/main/java/com/nonid/internal/animation/network/AnimationSpeedUpdateS2CPayload.java new file mode 100644 index 0000000..55f3b5a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/AnimationSpeedUpdateS2CPayload.java @@ -0,0 +1,31 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record AnimationSpeedUpdateS2CPayload(UUID instanceId, double speed, long stageStartTick, long stageEndTick) implements CustomPayload { + public static final Identifier ID_RAW = AnimationEngine.runtimeId("animation_speed_update"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + AnimationSpeedUpdateS2CPayload::instanceId, + PacketCodecs.DOUBLE, + AnimationSpeedUpdateS2CPayload::speed, + PacketCodecs.LONG, + AnimationSpeedUpdateS2CPayload::stageStartTick, + PacketCodecs.LONG, + AnimationSpeedUpdateS2CPayload::stageEndTick, + AnimationSpeedUpdateS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/AnimationStageInfo.java b/src/main/java/com/nonid/internal/animation/network/AnimationStageInfo.java new file mode 100644 index 0000000..b93d995 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/AnimationStageInfo.java @@ -0,0 +1,318 @@ +package com.nonid.internal.animation.network; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import com.nonid.api.client.NonAnimationRenderEvents; +import com.nonid.internal.animation.util.StageTimeWarp; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.item.ItemConvertible; +import net.minecraft.item.ItemDisplayContext; +import net.minecraft.item.ItemStack; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record AnimationStageInfo( + Identifier animationId, + boolean loop, + long lengthTicks, + boolean allowJoin, + double speed, + Identifier playbackAnimationId, + List actorProps, + double cycleMidpointOffsetSeconds, + double cycleTicks +) { + public static final Codec DATA_CODEC = RecordCodecBuilder.create( + instance -> instance.group( + Identifier.CODEC.fieldOf("id").forGetter(AnimationStageInfo::animationId), + Codec.BOOL.optionalFieldOf("loop", true).forGetter(AnimationStageInfo::loop), + Codec.DOUBLE.optionalFieldOf("cycle_seconds", 0.0).forGetter(stage -> exactTicksToSeconds(stage.cycleTicks())), + Codec.BOOL.optionalFieldOf("allow_join", true).forGetter(AnimationStageInfo::allowJoin), + Codec.DOUBLE.optionalFieldOf("speed", 1.0).forGetter(AnimationStageInfo::speed), + Codec.DOUBLE.optionalFieldOf("cycle_midpoint_offset_seconds", 0.0).forGetter(AnimationStageInfo::cycleMidpointOffsetSeconds) + ) + .apply( + instance, + (animationId, loop, cycleSeconds, allowJoin, speed, offset) -> new AnimationStageInfo( + animationId, loop, secondsToTicks(cycleSeconds), allowJoin, speed, animationId, List.of(), offset, secondsToExactTicks(cycleSeconds) + ) + ) + ); + public static final PacketCodec CODEC = PacketCodec.tuple( + Identifier.PACKET_CODEC.cast(), + AnimationStageInfo::animationId, + PacketCodecs.BOOLEAN, + AnimationStageInfo::loop, + PacketCodecs.LONG, + AnimationStageInfo::lengthTicks, + PacketCodecs.BOOLEAN, + AnimationStageInfo::allowJoin, + PacketCodecs.DOUBLE, + AnimationStageInfo::speed, + Identifier.PACKET_CODEC.cast(), + AnimationStageInfo::playbackAnimationId, + AnimationStageInfo.ActorPropEntry.CODEC.collect(PacketCodecs.toList()).xmap(List::copyOf, list -> list).cast(), + AnimationStageInfo::actorProps, + PacketCodecs.DOUBLE, + AnimationStageInfo::cycleMidpointOffsetSeconds, + PacketCodecs.DOUBLE, + AnimationStageInfo::cycleTicks, + AnimationStageInfo::new + ); + + public AnimationStageInfo { + if (animationId == null) { + throw new IllegalArgumentException("animationId must not be null"); + } + + if (playbackAnimationId == null) { + playbackAnimationId = animationId; + } + + if (lengthTicks < 0L) { + lengthTicks = 0L; + } + + if (!Double.isFinite(cycleTicks) || cycleTicks <= 0.0) { + cycleTicks = lengthTicks > 0L ? lengthTicks : 0.0; + } + + actorProps = sanitizeActorProps(actorProps); + if (loop && !(cycleTicks <= 0.0) && Double.isFinite(cycleMidpointOffsetSeconds)) { + cycleMidpointOffsetSeconds = StageTimeWarp.clampOffsetSeconds(cycleTicks, cycleMidpointOffsetSeconds); + } else { + cycleMidpointOffsetSeconds = 0.0; + } + } + + public AnimationStageInfo(Identifier animationId, boolean loop, long lengthTicks, boolean allowJoin, double speed) { + this(animationId, loop, lengthTicks, allowJoin, speed, animationId, List.of(), 0.0, lengthTicks); + } + + public AnimationStageInfo(Identifier animationId, boolean loop, long lengthTicks, boolean allowJoin, double speed, Identifier playbackAnimationId) { + this(animationId, loop, lengthTicks, allowJoin, speed, playbackAnimationId, List.of(), 0.0, lengthTicks); + } + + public AnimationStageInfo( + Identifier animationId, + boolean loop, + long lengthTicks, + boolean allowJoin, + double speed, + Identifier playbackAnimationId, + List actorProps + ) { + this(animationId, loop, lengthTicks, allowJoin, speed, playbackAnimationId, actorProps, 0.0, lengthTicks); + } + + public AnimationStageInfo( + Identifier animationId, + boolean loop, + long lengthTicks, + boolean allowJoin, + double speed, + Identifier playbackAnimationId, + List actorProps, + double cycleMidpointOffsetSeconds + ) { + this(animationId, loop, lengthTicks, allowJoin, speed, playbackAnimationId, actorProps, cycleMidpointOffsetSeconds, lengthTicks); + } + + private static long secondsToTicks(double seconds) { + return Double.isFinite(seconds) && !(seconds <= 0.0) ? Math.max(1L, (long)Math.ceil(seconds * 20.0)) : 0L; + } + + private static double ticksToSeconds(long ticks) { + return ticks <= 0L ? 0.0 : ticks / 20.0; + } + + private static double secondsToExactTicks(double seconds) { + return Double.isFinite(seconds) && !(seconds <= 0.0) ? seconds * 20.0 : 0.0; + } + + private static double exactTicksToSeconds(double ticks) { + return Double.isFinite(ticks) && !(ticks <= 0.0) ? ticks / 20.0 : 0.0; + } + + @Nullable + public AnimationStageInfo.ActorPropEntry actorPropsForKey(@Nullable String actorKey) { + if (actorKey != null && !actorKey.isBlank() && !this.actorProps.isEmpty()) { + for (AnimationStageInfo.ActorPropEntry entry : this.actorProps) { + if (entry != null && actorKey.equals(entry.actorKey())) { + return entry; + } + } + + return null; + } else { + return null; + } + } + + public AnimationStageInfo withActorProp(String actorKey, AnimationStageInfo.PropSlot slot, Identifier itemId) { + if (actorKey != null && !actorKey.isBlank() && slot != null && itemId != null) { + List updated = new ArrayList<>(this.actorProps); + boolean replaced = false; + + for (int i = 0; i < updated.size(); i++) { + AnimationStageInfo.ActorPropEntry entry = updated.get(i); + if (entry != null && actorKey.equals(entry.actorKey())) { + updated.set(i, withProp(entry, slot, itemId)); + replaced = true; + break; + } + } + + if (!replaced) { + updated.add(withProp(new AnimationStageInfo.ActorPropEntry(actorKey, null, null, null), slot, itemId)); + } + + return new AnimationStageInfo( + this.animationId, + this.loop, + this.lengthTicks, + this.allowJoin, + this.speed, + this.playbackAnimationId, + updated, + this.cycleMidpointOffsetSeconds, + this.cycleTicks + ); + } else { + return this; + } + } + + private static AnimationStageInfo.ActorPropEntry withProp(AnimationStageInfo.ActorPropEntry entry, AnimationStageInfo.PropSlot slot, Identifier itemId) { + return switch (slot) { + case LEFT -> new AnimationStageInfo.ActorPropEntry(entry.actorKey(), itemId, entry.propRightItemId(), entry.propFloorItemId()); + case RIGHT -> new AnimationStageInfo.ActorPropEntry(entry.actorKey(), entry.propLeftItemId(), itemId, entry.propFloorItemId()); + case FLOOR -> new AnimationStageInfo.ActorPropEntry(entry.actorKey(), entry.propLeftItemId(), entry.propRightItemId(), itemId); + }; + } + + public Identifier effectiveAnimationId() { + return this.playbackAnimationId; + } + + private static List sanitizeActorProps(List props) { + if (props != null && !props.isEmpty()) { + Map dedup = new LinkedHashMap<>(); + + for (AnimationStageInfo.ActorPropEntry entry : props) { + if (entry != null) { + String key = entry.actorKey() == null ? "" : entry.actorKey().trim(); + if (!key.isBlank()) { + Identifier left = entry.propLeftItemId(); + Identifier right = entry.propRightItemId(); + Identifier floor = entry.propFloorItemId(); + if (left != null || right != null || floor != null) { + dedup.put(key, new AnimationStageInfo.ActorPropEntry(key, left, right, floor)); + } + } + } + } + + if (dedup.isEmpty()) { + return List.of(); + } + + List out = new ArrayList<>(dedup.values()); + return List.copyOf(out); + } else { + return List.of(); + } + } + + public record ActorPropEntry( + String actorKey, @Nullable Identifier propLeftItemId, @Nullable Identifier propRightItemId, @Nullable Identifier propFloorItemId + ) { + private static final PacketCodec NULLABLE_IDENTIFIER_STRING_CODEC = PacketCodecs.STRING.cast(); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.STRING, + AnimationStageInfo.ActorPropEntry::actorKey, + NULLABLE_IDENTIFIER_STRING_CODEC, + entry -> identifierToString(entry.propLeftItemId()), + NULLABLE_IDENTIFIER_STRING_CODEC, + entry -> identifierToString(entry.propRightItemId()), + NULLABLE_IDENTIFIER_STRING_CODEC, + entry -> identifierToString(entry.propFloorItemId()), + (actorKey, leftId, rightId, floorId) -> new AnimationStageInfo.ActorPropEntry( + actorKey, parseIdentifier(leftId), parseIdentifier(rightId), parseIdentifier(floorId) + ) + ); + + public ActorPropEntry { + actorKey = actorKey == null ? "" : actorKey.trim(); + } + + public boolean isEmpty() { + return this.propLeftItemId == null && this.propRightItemId == null && this.propFloorItemId == null; + } + + @Nullable + public ItemStack leftStack() { + return createStack(this.propLeftItemId); + } + + @Nullable + public ItemStack rightStack() { + return createStack(this.propRightItemId); + } + + @Nullable + public ItemStack floorStack() { + return createStack(this.propFloorItemId); + } + + @Nullable + public NonAnimationRenderEvents.BoneItemProp leftBoneProp() { + ItemStack stack = this.leftStack(); + return stack != null && !stack.isEmpty() ? new NonAnimationRenderEvents.BoneItemProp(stack, ItemDisplayContext.THIRD_PERSON_LEFT_HAND) : null; + } + + @Nullable + public NonAnimationRenderEvents.BoneItemProp rightBoneProp() { + ItemStack stack = this.rightStack(); + return stack != null && !stack.isEmpty() ? new NonAnimationRenderEvents.BoneItemProp(stack, ItemDisplayContext.THIRD_PERSON_RIGHT_HAND) : null; + } + + @Nullable + public NonAnimationRenderEvents.BoneItemProp floorBoneProp() { + ItemStack stack = this.floorStack(); + return stack != null && !stack.isEmpty() ? new NonAnimationRenderEvents.BoneItemProp(stack, ItemDisplayContext.GROUND) : null; + } + + @Nullable + private static ItemStack createStack(@Nullable Identifier itemId) { + return itemId != null && Registries.ITEM.containsId(itemId) ? new ItemStack((ItemConvertible)Registries.ITEM.get(itemId)) : null; + } + + @Nullable + private static Identifier parseIdentifier(String raw) { + if (raw != null && !raw.isBlank()) { + Identifier id = Identifier.tryParse(raw); + return id != null && Registries.ITEM.containsId(id) ? id : null; + } else { + return null; + } + } + + private static String identifierToString(@Nullable Identifier id) { + return id == null ? "" : id.toString(); + } + } + + public enum PropSlot { + LEFT, + RIGHT, + FLOOR; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugAdvanceStageC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugAdvanceStageC2SPayload.java new file mode 100644 index 0000000..687c30b --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugAdvanceStageC2SPayload.java @@ -0,0 +1,22 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record DebugAdvanceStageC2SPayload(UUID instanceId) implements CustomPayload { + public static final Identifier DEBUG_ADVANCE_STAGE_ID = AnimationEngine.runtimeId("debug_advance_stage"); + public static final Id ID = new Id(DEBUG_ADVANCE_STAGE_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, DebugAdvanceStageC2SPayload::instanceId, DebugAdvanceStageC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugChatPreferenceC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugChatPreferenceC2SPayload.java new file mode 100644 index 0000000..1ec067a --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugChatPreferenceC2SPayload.java @@ -0,0 +1,28 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DebugChatPreferenceC2SPayload(String modeId) implements CustomPayload { + public static final Identifier DEBUG_CHAT_PREF_ID = AnimationEngine.runtimeId("debug_chat_pref"); + public static final Id ID = new Id(DEBUG_CHAT_PREF_ID); + public static final PacketCodec CODEC = PacketCodec.of( + DebugChatPreferenceC2SPayload::encode, DebugChatPreferenceC2SPayload::decode + ); + + public Id getId() { + return ID; + } + + private static void encode(DebugChatPreferenceC2SPayload payload, RegistryByteBuf buf) { + buf.writeString(payload.modeId()); + } + + private static DebugChatPreferenceC2SPayload decode(RegistryByteBuf buf) { + return new DebugChatPreferenceC2SPayload(buf.readString(64)); + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugJoinAnimationC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugJoinAnimationC2SPayload.java new file mode 100644 index 0000000..c7ea61e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugJoinAnimationC2SPayload.java @@ -0,0 +1,28 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record DebugJoinAnimationC2SPayload(UUID instanceId, List actorEntityIds) implements CustomPayload { + public static final Identifier DEBUG_JOIN_ANIMATION_ID = AnimationEngine.runtimeId("debug_join_animation"); + public static final Id ID = new Id(DEBUG_JOIN_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + DebugJoinAnimationC2SPayload::instanceId, + PacketCodecs.INTEGER.collect(PacketCodecs.toList(32)), + DebugJoinAnimationC2SPayload::actorEntityIds, + DebugJoinAnimationC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugStartAnimationC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugStartAnimationC2SPayload.java new file mode 100644 index 0000000..ef3704f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugStartAnimationC2SPayload.java @@ -0,0 +1,31 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.List; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DebugStartAnimationC2SPayload(List actorEntityIds, String damageBehaviorId, boolean ignoreAttackers, int anchorEntityId) + implements CustomPayload { + public static final Identifier DEBUG_START_ANIMATION_ID = AnimationEngine.runtimeId("debug_start_animation"); + public static final Id ID = new Id(DEBUG_START_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.INTEGER.collect(PacketCodecs.toList(32)), + DebugStartAnimationC2SPayload::actorEntityIds, + PacketCodecs.STRING, + DebugStartAnimationC2SPayload::damageBehaviorId, + PacketCodecs.BOOLEAN, + DebugStartAnimationC2SPayload::ignoreAttackers, + PacketCodecs.INTEGER, + DebugStartAnimationC2SPayload::anchorEntityId, + DebugStartAnimationC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugStopAllAnimationsC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugStopAllAnimationsC2SPayload.java new file mode 100644 index 0000000..a7435c3 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugStopAllAnimationsC2SPayload.java @@ -0,0 +1,18 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DebugStopAllAnimationsC2SPayload() implements CustomPayload { + public static final Identifier DEBUG_STOP_ALL_ID = AnimationEngine.runtimeId("debug_stop_all"); + public static final Id ID = new Id(DEBUG_STOP_ALL_ID); + public static final PacketCodec CODEC = PacketCodec.unit(new DebugStopAllAnimationsC2SPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/DebugStopAnimationC2SPayload.java b/src/main/java/com/nonid/internal/animation/network/DebugStopAnimationC2SPayload.java new file mode 100644 index 0000000..25ce541 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/DebugStopAnimationC2SPayload.java @@ -0,0 +1,22 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record DebugStopAnimationC2SPayload(UUID instanceId) implements CustomPayload { + public static final Identifier DEBUG_STOP_ANIMATION_ID = AnimationEngine.runtimeId("debug_stop_animation"); + public static final Id ID = new Id(DEBUG_STOP_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, DebugStopAnimationC2SPayload::instanceId, DebugStopAnimationC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/StartAnimationS2CPayload.java b/src/main/java/com/nonid/internal/animation/network/StartAnimationS2CPayload.java new file mode 100644 index 0000000..7b1b388 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/StartAnimationS2CPayload.java @@ -0,0 +1,227 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.Nullable; + +public record StartAnimationS2CPayload( + Identifier animationId, + List> modelRootsByActor, + UUID instanceId, + List actorUuids, + List actorKeys, + List stages, + long startTick, + int stageIndex, + long stageStartTick, + long stageEndTick, + double speed, + boolean lockOrientation, + float lockedYaw, + float lockedHeadYaw, + float lockedPitch, + @Nullable Vec3d cameraOrbitTarget +) implements CustomPayload { + public static final Identifier START_ANIMATION_ID = AnimationEngine.runtimeId("start_animation"); + public static final Id ID = new Id(START_ANIMATION_ID); + private static final int MAX_ACTORS = 16; + private static final int MAX_STAGES = 16; + private static final int MAX_MODEL_ROOTS_PER_ACTOR = 32; + public static final PacketCodec CODEC = PacketCodec.of( + StartAnimationS2CPayload::encode, StartAnimationS2CPayload::decode + ); + + public StartAnimationS2CPayload { + actorUuids = List.copyOf(actorUuids); + modelRootsByActor = modelRootsByActor != null && !modelRootsByActor.isEmpty() + ? modelRootsByActor.stream().map(rootsx -> rootsx == null ? List.of() : List.copyOf(rootsx)).toList() + : Collections.nCopies(actorUuids.size(), List.of()); + actorKeys = actorKeys == null ? List.of() : List.copyOf(actorKeys); + stages = stages == null ? List.of() : List.copyOf(stages); + if (actorUuids.size() > 16) { + throw new IllegalArgumentException("Too many actor UUIDs: " + actorUuids.size()); + } + + if (modelRootsByActor.size() != actorUuids.size()) { + throw new IllegalArgumentException("modelRootsByActor size mismatch: " + modelRootsByActor.size()); + } + + for (List roots : modelRootsByActor) { + if (roots.size() > 32) { + throw new IllegalArgumentException("Too many model roots for actor: " + roots.size()); + } + } + + if (!actorKeys.isEmpty() && actorKeys.size() != actorUuids.size()) { + throw new IllegalArgumentException("actorKeys size mismatch: " + actorKeys.size()); + } + + if (stages.size() > 16) { + throw new IllegalArgumentException("Too many stages: " + stages.size()); + } + + stageIndex = stages.isEmpty() ? 0 : Math.max(0, Math.min(stageIndex, stages.size() - 1)); + stageStartTick = Math.max(startTick, stageStartTick); + if (Double.isNaN(speed) || Double.isInfinite(speed) || speed <= 0.0) { + speed = 1.0; + } + + if (!lockOrientation) { + lockedYaw = 0.0F; + lockedHeadYaw = 0.0F; + lockedPitch = 0.0F; + } + + if (cameraOrbitTarget != null && !cameraOrbitTarget.isFinite()) { + cameraOrbitTarget = null; + } + } + + public Id getId() { + return ID; + } + + private static void encode(StartAnimationS2CPayload payload, RegistryByteBuf buf) { + Identifier.PACKET_CODEC.encode(buf, payload.animationId()); + Uuids.PACKET_CODEC.encode(buf, payload.instanceId()); + buf.writeVarInt(payload.actorUuids().size()); + + for (UUID uuid : payload.actorUuids()) { + Uuids.PACKET_CODEC.encode(buf, uuid); + } + + for (List roots : payload.modelRootsByActor()) { + buf.writeVarInt(roots.size()); + + for (Identifier root : roots) { + Identifier.PACKET_CODEC.encode(buf, root); + } + } + + buf.writeVarInt(payload.actorKeys().size()); + + for (String key : payload.actorKeys()) { + buf.writeString(key); + } + + buf.writeVarInt(payload.stages().size()); + + for (AnimationStageInfo stage : payload.stages()) { + AnimationStageInfo.CODEC.encode(buf, stage); + } + + buf.writeLong(payload.startTick()); + buf.writeVarInt(payload.stageIndex()); + buf.writeLong(payload.stageStartTick()); + buf.writeLong(payload.stageEndTick()); + buf.writeDouble(payload.speed()); + buf.writeBoolean(payload.lockOrientation()); + buf.writeFloat(payload.lockedYaw()); + buf.writeFloat(payload.lockedHeadYaw()); + buf.writeFloat(payload.lockedPitch()); + buf.writeBoolean(payload.cameraOrbitTarget() != null); + if (payload.cameraOrbitTarget() != null) { + Vec3d target = payload.cameraOrbitTarget(); + buf.writeDouble(target.x); + buf.writeDouble(target.y); + buf.writeDouble(target.z); + } + } + + private static StartAnimationS2CPayload decode(RegistryByteBuf buf) { + Identifier animationId = (Identifier)Identifier.PACKET_CODEC.decode(buf); + UUID instanceId = (UUID)Uuids.PACKET_CODEC.decode(buf); + int actorCount = buf.readVarInt(); + if (actorCount >= 0 && actorCount <= 16) { + List actorUuids = new ArrayList<>(actorCount); + + for (int i = 0; i < actorCount; i++) { + actorUuids.add((UUID)Uuids.PACKET_CODEC.decode(buf)); + } + + List> modelRootsByActor = new ArrayList<>(actorCount); + + for (int i = 0; i < actorCount; i++) { + int rootCount = buf.readVarInt(); + if (rootCount < 0 || rootCount > 32) { + throw new IllegalArgumentException("Too many model roots for actor: " + rootCount); + } + + List roots = new ArrayList<>(rootCount); + + for (int rootIndex = 0; rootIndex < rootCount; rootIndex++) { + roots.add((Identifier)Identifier.PACKET_CODEC.decode(buf)); + } + + modelRootsByActor.add(List.copyOf(roots)); + } + + int keyCount = buf.readVarInt(); + if (keyCount >= 0 && keyCount <= 16) { + List actorKeys = new ArrayList<>(keyCount); + + for (int i = 0; i < keyCount; i++) { + actorKeys.add(buf.readString(128)); + } + + int stageCount = buf.readVarInt(); + if (stageCount >= 0 && stageCount <= 16) { + List stages = new ArrayList<>(stageCount); + + for (int i = 0; i < stageCount; i++) { + stages.add((AnimationStageInfo)AnimationStageInfo.CODEC.decode(buf)); + } + + long startTick = buf.readLong(); + int stageIndex = buf.readVarInt(); + long stageStartTick = buf.readLong(); + long stageEndTick = buf.readLong(); + double speed = buf.readDouble(); + boolean lockOrientation = buf.readBoolean(); + float lockedYaw = buf.readFloat(); + float lockedHeadYaw = buf.readFloat(); + float lockedPitch = buf.readFloat(); + Vec3d cameraOrbitTarget = null; + if (buf.readBoolean()) { + cameraOrbitTarget = new Vec3d(buf.readDouble(), buf.readDouble(), buf.readDouble()); + } + + return new StartAnimationS2CPayload( + animationId, + modelRootsByActor, + instanceId, + actorUuids, + actorKeys, + stages, + startTick, + stageIndex, + stageStartTick, + stageEndTick, + speed, + lockOrientation, + lockedYaw, + lockedHeadYaw, + lockedPitch, + cameraOrbitTarget + ); + } else { + throw new IllegalArgumentException("Too many stages: " + stageCount); + } + } else { + throw new IllegalArgumentException("Too many actor keys: " + keyCount); + } + } else { + throw new IllegalArgumentException("Too many actor UUIDs: " + actorCount); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/StopAllAnimationsS2CPayload.java b/src/main/java/com/nonid/internal/animation/network/StopAllAnimationsS2CPayload.java new file mode 100644 index 0000000..aabaaa6 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/StopAllAnimationsS2CPayload.java @@ -0,0 +1,21 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record StopAllAnimationsS2CPayload(long stopTick) implements CustomPayload { + public static final Identifier STOP_ALL_ID = AnimationEngine.runtimeId("stop_all_animations"); + public static final Id ID = new Id(STOP_ALL_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.LONG, StopAllAnimationsS2CPayload::stopTick, StopAllAnimationsS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/network/StopAnimationS2CPayload.java b/src/main/java/com/nonid/internal/animation/network/StopAnimationS2CPayload.java new file mode 100644 index 0000000..193bbd3 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/network/StopAnimationS2CPayload.java @@ -0,0 +1,23 @@ +package com.nonid.internal.animation.network; + +import com.nonid.internal.animation.AnimationEngine; +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record StopAnimationS2CPayload(UUID instanceId, long stopTick) implements CustomPayload { + public static final Identifier STOP_ANIMATION_ID = AnimationEngine.runtimeId("stop_animation"); + public static final Id ID = new Id(STOP_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, StopAnimationS2CPayload::instanceId, PacketCodecs.LONG, StopAnimationS2CPayload::stopTick, StopAnimationS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/internal/animation/server/AnimationSessionData.java b/src/main/java/com/nonid/internal/animation/server/AnimationSessionData.java new file mode 100644 index 0000000..7e4ce60 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/server/AnimationSessionData.java @@ -0,0 +1,4 @@ +package com.nonid.internal.animation.server; + +public interface AnimationSessionData { +} diff --git a/src/main/java/com/nonid/internal/animation/server/AnimationStageTiming.java b/src/main/java/com/nonid/internal/animation/server/AnimationStageTiming.java new file mode 100644 index 0000000..fdda925 --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/server/AnimationStageTiming.java @@ -0,0 +1,76 @@ +package com.nonid.internal.animation.server; + +import com.nonid.NeedsOfNature; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.util.AnimationIds; +import java.util.Objects; +import net.minecraft.util.Identifier; + +final class AnimationStageTiming { + static final long NO_AUTOMATIC_END = -1L; + + private AnimationStageTiming() { + } + + static long resolveDurationTicks(Identifier animationId, AnimationStageInfo stage, double speed) { + if (stage == null) { + return -1L; + } else { + double safeSpeed = Double.isFinite(speed) && speed > 0.0 ? speed : 1.0; + if (!stage.loop()) { + double authoredTicks = exactCycleTicks(stage); + return authoredTicks <= 0.0 ? -1L : Math.max(1L, (long)Math.ceil(authoredTicks / safeSpeed)); + } else { + long targetTicks = resolveLoopTargetTicks(animationId, stage.animationId()); + if (targetTicks < 0L) { + return -1L; + } else { + double cycleTicks = exactCycleTicks(stage); + if (cycleTicks <= 0.0) { + return targetTicks; + } else { + double worldCycleTicks = cycleTicks / safeSpeed; + if (Double.isFinite(worldCycleTicks) && !(worldCycleTicks <= 0.0)) { + long cycles = Math.max(1L, Math.round(targetTicks / worldCycleTicks)); + return Math.max(1L, Math.round(cycles * worldCycleTicks)); + } else { + return targetTicks; + } + } + } + } + } + } + + private static long resolveLoopTargetTicks(Identifier animationId, Identifier stageId) { + Identifier baseId = AnimationIds.baseId(animationId == null ? stageId : animationId); + Integer seconds = AnimationGameplayMetadata.stageSeconds(stageId); + if (seconds == null) { + seconds = AnimationGameplayMetadata.stageSeconds(baseId); + } + + if (seconds != null && seconds < 0) { + return -1L; + } + + if (seconds == null) { + Integer peakStage = AnimationGameplayMetadata.peakStage(baseId); + seconds = Objects.equals(AnimationIds.stageNumber(stageId), peakStage) + ? NeedsOfNature.getConfig().getPeakLoopProgressSeconds() + : NeedsOfNature.getConfig().getLoopProgressSeconds(); + } + + double multiplier = AnimationGameplayMetadata.stageDurationMultiplier(stageId); + return Math.max(1L, Math.round(seconds.intValue() * 20.0 * multiplier)); + } + + private static double exactCycleTicks(AnimationStageInfo stage) { + double metadataTicks = AnimationGameplayMetadata.cycleTicks(stage.animationId()); + if (metadataTicks > 0.0) { + return metadataTicks; + } else { + return Double.isFinite(stage.cycleTicks()) && stage.cycleTicks() > 0.0 ? stage.cycleTicks() : Math.max(0L, stage.lengthTicks()); + } + } +} diff --git a/src/main/java/com/nonid/internal/animation/server/AnimationStartContext.java b/src/main/java/com/nonid/internal/animation/server/AnimationStartContext.java new file mode 100644 index 0000000..e90bf9f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/server/AnimationStartContext.java @@ -0,0 +1,39 @@ +package com.nonid.internal.animation.server; + +import java.util.Map; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record AnimationStartContext( + Map addonMetadata, @Nullable AnimationSessionData sessionData, @Nullable Identifier joinPredecessor, boolean queueContinuation +) { + private static final AnimationStartContext EMPTY = new AnimationStartContext(Map.of(), null, null, false); + + public AnimationStartContext { + addonMetadata = addonMetadata != null && !addonMetadata.isEmpty() ? Map.copyOf(addonMetadata) : Map.of(); + } + + public static AnimationStartContext empty() { + return EMPTY; + } + + public static AnimationStartContext addonMetadata(@Nullable Map metadata) { + return metadata != null && !metadata.isEmpty() ? new AnimationStartContext(metadata, null, null, false) : EMPTY; + } + + public AnimationStartContext withSessionData(@Nullable AnimationSessionData data) { + return new AnimationStartContext(this.addonMetadata, data, this.joinPredecessor, this.queueContinuation); + } + + public AnimationStartContext withJoinPredecessor(@Nullable Identifier predecessor) { + return new AnimationStartContext(this.addonMetadata, this.sessionData, predecessor, this.queueContinuation); + } + + public AnimationStartContext asQueueContinuation() { + return this.queueContinuation ? this : new AnimationStartContext(this.addonMetadata, this.sessionData, this.joinPredecessor, true); + } + + public boolean isJoinReplacement() { + return this.joinPredecessor != null; + } +} diff --git a/src/main/java/com/nonid/internal/animation/server/ServerAnimationController.java b/src/main/java/com/nonid/internal/animation/server/ServerAnimationController.java new file mode 100644 index 0000000..3fd11dc --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/server/ServerAnimationController.java @@ -0,0 +1,4466 @@ +package com.nonid.internal.animation.server; + +import com.nonid.NeedsOfNature; +import com.nonid.NonDebugChatCategory; +import com.nonid.api.animation.NonAnimationDamageBehavior; +import com.nonid.internal.animation.AnimationEngine; +import com.nonid.internal.animation.api.AnimationLifecycleEvents; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AdvanceAnimationStageS2CPayload; +import com.nonid.internal.animation.network.AnimationSpeedUpdateS2CPayload; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.internal.animation.network.StartAnimationS2CPayload; +import com.nonid.internal.animation.network.StopAnimationS2CPayload; +import com.nonid.internal.animation.util.WeightedPicker; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDamage; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents.AfterDeath; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.ServerStarted; +import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.Disconnect; +import net.minecraft.block.BedBlock; +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.SlabBlock; +import net.minecraft.block.enums.BedPart; +import net.minecraft.block.enums.SlabType; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.damage.DamageTypes; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryKey; +import net.minecraft.registry.RegistryKeys; +import net.minecraft.registry.tag.BlockTags; +import net.minecraft.registry.tag.FluidTags; +import net.minecraft.registry.tag.TagKey; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.state.property.Property; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.function.BooleanBiFunction; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult.Type; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.math.BlockPos.Mutable; +import net.minecraft.util.math.Direction.Axis; +import net.minecraft.util.math.random.Random; +import net.minecraft.util.shape.VoxelShape; +import net.minecraft.util.shape.VoxelShapes; +import net.minecraft.world.RaycastContext; +import net.minecraft.world.World; +import net.minecraft.world.RaycastContext.FluidHandling; +import net.minecraft.world.RaycastContext.ShapeType; +import org.jetbrains.annotations.Nullable; + +public final class ServerAnimationController { + private static final double DEBUG_MULTI_PLAYER_REQUESTER_MAX_DIST = 5.0; + private static final int BLOCK_SCAN_VERTICAL_RANGE = 1; + private static final double HALF_HEIGHT_SUPPORT_TOP_OFFSET = 0.5625; + private static final boolean BLOCK_REQUIRE_SOLID_BELOW = true; + private static final double FLOOR_MIN_HEIGHT = 0.875; + private static final double FLOOR_SUPPORT_INSET = 0.0625; + private static final double FLOOR_SUPPORT_THICKNESS = 0.0625; + private static final double WALL_CORE_INSET = 0.0625; + private static final double WALL_CORE_MIN_HEIGHT = 0.875; + private static final double WALL_EDGE_EPSILON = 1.0E-6; + private static final double WALL_ANCHOR_DISTANCE = 0.5; + private static final double WALL_BROAD_FACE_MIN_SPAN = 0.5; + private static final double GROUND_PLACEMENT_SEARCH_RADIUS = 2.0; + private static final double GROUND_PLACEMENT_SEARCH_STEP = 0.5; + private static final double GROUND_PLACEMENT_CLEARANCE_HALF_EXTENT = 0.75; + private static final double GROUND_PLACEMENT_CLEARANCE_HEIGHT = 2.0; + private static final double GROUND_PLACEMENT_FLOOR_EPSILON = 0.01; + private static final double PLAYER_LOCK_EPSILON_SQ = 4.0E-4; + private static final int IN_WALL_GRACE_TICKS = 5; + private static final int PLAYER_NUDGE_INTERVAL_TICKS = 4; + private static final int PLAYER_NUDGE_MAX_MOBS_PER_PLAYER = 6; + private static final double PLAYER_NUDGE_SCAN_RADIUS = 1.0; + private static final double PLAYER_NUDGE_MIN_DISTANCE = 0.85; + private static final double PLAYER_NUDGE_STRENGTH = 0.08; + private static final double PLAYER_NUDGE_EPSILON = 1.0E-6; + private static final int RECENT_NO_RESTORE_TRANSITION_TICKS = 20; + private static final Identifier BEDS_BLOCK_TAG_ID = Identifier.of("minecraft", "beds"); + private static final TagKey WALL_SURFACES_BLOCK_TAG = TagKey.of(RegistryKeys.BLOCK, AnimationEngine.packId("wall_surfaces")); + private static final Direction[] HORIZONTAL_DIRECTIONS = new Direction[]{Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + private static final Map, Map> IN_WALL_GRACE_BY_WORLD = new HashMap<>(); + private static final Map, Map> RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD = new HashMap<>(); + private static final String ANIMATION_NOAI_TAG = "afw_noai"; + private static final String ANIMATION_NOAI_ORIG_TAG = "afw_noai_orig"; + private static final int NOAI_CLEANUP_INTERVAL_TICKS = 6000; + private static final int STARTUP_NOAI_CLEANUP_TICKS = 20; + private static final int STARTUP_START_BLOCK_TICKS = 40; + private static final List GROUND_PLACEMENT_OFFSETS = createGroundPlacementOffsets(); + private static boolean initialized = false; + private static int STARTUP_NOAI_CLEANUP_UNTIL_TICK = Integer.MIN_VALUE; + private static int STARTUP_START_BLOCK_UNTIL_TICK = Integer.MIN_VALUE; + private static final Map ACTIVE_INSTANCES = new ConcurrentHashMap<>(); + private static final Map, Map> ACTIVE_INSTANCE_BY_ACTOR = new ConcurrentHashMap<>(); + private static final Map ANIMATION_START_PROTECTION = new ConcurrentHashMap<>(); + private static final int PLAYER_QUEUE_CAP = 8; + private static final Map, Map> PLAYER_QUEUES_BY_WORLD = new HashMap<>(); + private static final Map, Map> QUEUED_MOB_TO_PLAYER_BY_WORLD = new HashMap<>(); + private static final Map, Map> DEFERRED_QUEUE_CONTEXT_BY_WORLD = new HashMap<>(); + private static final Map, Map> AI_DISABLE_STATE_BY_WORLD = new HashMap<>(); + private static final Map, Integer> NOAI_CLEANUP_TICKS_BY_WORLD = new HashMap<>(); + private static final Map, Map> PLAYER_LOCK_STATE_BY_WORLD = new HashMap<>(); + private static final int MAX_FLOOR_SNAP_DROP = 2; + + private ServerAnimationController() { + } + + public static void init() { + if (!initialized) { + initialized = true; + ServerLifecycleEvents.SERVER_STARTED.register((ServerStarted)server -> { + AI_DISABLE_STATE_BY_WORLD.clear(); + NOAI_CLEANUP_TICKS_BY_WORLD.clear(); + STARTUP_NOAI_CLEANUP_UNTIL_TICK = server.getTicks() + 20; + STARTUP_START_BLOCK_UNTIL_TICK = server.getTicks() + 40; + PLAYER_LOCK_STATE_BY_WORLD.clear(); + ACTIVE_INSTANCES.clear(); + ACTIVE_INSTANCE_BY_ACTOR.clear(); + PLAYER_QUEUES_BY_WORLD.clear(); + QUEUED_MOB_TO_PLAYER_BY_WORLD.clear(); + DEFERRED_QUEUE_CONTEXT_BY_WORLD.clear(); + ANIMATION_START_PROTECTION.clear(); + RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.clear(); + }); + ServerPlayConnectionEvents.DISCONNECT.register((Disconnect)(handler, server) -> server.execute(() -> { + ServerPlayerEntity player = handler.player; + if (player != null) { + if (player.getEntityWorld() instanceof ServerWorld world) { + stopActiveInstancesForDisconnectingPlayer(world, player.getUuid()); + releasePlayerLocks(world, List.of(player.getUuid())); + clearPlayerQueue(world, player); + clearDeferredQueueOriginal(world, player.getUuid()); + ANIMATION_START_PROTECTION.remove(player.getUuid()); + removePlayerFromInstanceSubscribers(player.getUuid()); + } + } + })); + ServerLivingEntityEvents.AFTER_DEATH.register((AfterDeath)(entity, damageSource) -> { + if (entity.getEntityWorld() instanceof ServerWorld world) { + stopActiveInstancesForDeadActor(world, entity); + } + }); + ServerLivingEntityEvents.AFTER_DAMAGE.register((AfterDamage)(entity, damageSource, baseDamageTaken, damageTaken, blocked) -> { + if (!blocked && !(damageTaken <= 0.0F)) { + if (entity.getEntityWorld() instanceof ServerWorld world) { + stopDamageSensitiveInstances(world, entity); + } + } + }); + ServerTickEvents.END_WORLD_TICK.register(ServerAnimationController::tickWorld); + } + } + + @Nullable + public static UUID startNowWithContext( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + Identifier animationId, + List actorsSorted, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable UUID positionAnchorUuid, + @Nullable AnimationStartContext startContext, + boolean forceChat, + boolean allowMultiplePlayers + ) { + if (world == null || animationId == null || actorsSorted == null || actorsSorted.isEmpty()) { + return null; + } + + if (isWithinStartupStartBlockWindow(world)) { + return null; + } + + if (!AnimationDefinitions.isAnimationEnabled(animationId)) { + return null; + } + + List actors = new ArrayList<>(actorsSorted.size()); + actors.addAll(actorsSorted); + actors.sort(Comparator.comparingInt(Entity::getId)); + int players = 0; + List actorUuids = new ArrayList<>(actors.size()); + + for (Entity e : actors) { + if (e == null || e.getEntityWorld() != world) { + return null; + } + + if (e instanceof PlayerEntity) { + players++; + } else if (!(e instanceof MobEntity)) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("matched_unpathable_actor", animationId), forceChat); + return null; + } + + actorUuids.add(e.getUuid()); + } + + boolean authorizedMultiPlayerRequest = players > 1 && requester != null && (allowMultiplePlayers || forceChat); + if (players > 1 && !authorizedMultiPlayerRequest) { + sendOrLogDebug(world, requester, NonDebugChatCategory.SETUP, dbg("matched_multiple_players_not_supported", animationId), forceChat); + return null; + } + + if (authorizedMultiPlayerRequest && !allSelectedPlayersNearRequester(actors, requester, 5.0)) { + sendOrLogDebug( + world, + requester, + NonDebugChatCategory.SETUP, + dbg("start_cancelled_players_too_far_from_requester", String.format(Locale.ROOT, "%.1f", 5.0)), + forceChat + ); + return null; + } + + if (shouldBlockStart(world, actorUuids)) { + return null; + } + + List safeActorKeys = actorKeys != null && actorKeys.size() == actorUuids.size() ? List.copyOf(actorKeys) : List.of(); + List safeStages = stages == null ? List.of() : List.copyOf(stages); + NonAnimationDamageBehavior safeBehavior = damageBehavior == null ? NonAnimationDamageBehavior.IGNORE_DAMAGE : damageBehavior; + return startAndBroadcast( + world, + requester, + animationId, + actorUuids, + safeActorKeys, + safeStages, + safeBehavior, + ignoreAttackers, + sanitizeStartContext(startContext), + forceChat, + positionAnchorUuid, + null, + null + ); + } + + @Nullable + public static ServerAnimationController.MatchedStartRequest startEligibleMatchedNow( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + List actorsSorted, + @Nullable Set requiredAnimationTags, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable UUID positionAnchorUuid, + @Nullable AnimationStartContext startContext, + boolean forceChat + ) { + if (world != null && actorsSorted != null && !actorsSorted.isEmpty()) { + List actors = new ArrayList<>(actorsSorted.size()); + actors.addAll(actorsSorted); + actors.sort(Comparator.comparingInt(Entity::getId)); + ServerAnimationController.EligibleDefinition eligible = selectEligibleDefinition( + world, actors, requiredAnimationTags == null ? Set.of() : requiredAnimationTags, positionAnchorUuid + ); + if (eligible == null) { + return null; + } + + UUID instanceId = startNowWithContext( + world, + requester, + eligible.definition().id(), + actors, + eligible.actorKeys(), + eligible.definition().stages(), + damageBehavior, + ignoreAttackers, + positionAnchorUuid, + startContext, + forceChat, + false + ); + return new ServerAnimationController.MatchedStartRequest(instanceId, eligible.definition().id(), eligible.actorKeys(), eligible.definition().stages()); + } else { + return null; + } + } + + public static boolean isDefinitionStartEligible( + ServerWorld world, @Nullable AnimationDefinitions.Definition definition, List actorsSorted, @Nullable UUID positionAnchorUuid + ) { + if (world != null && definition != null && actorsSorted != null && !actorsSorted.isEmpty()) { + List actors = new ArrayList<>(actorsSorted.size()); + actors.addAll(actorsSorted); + actors.sort(Comparator.comparingInt(Entity::getId)); + + for (Entity actor : actors) { + if (actor == null || actor.getEntityWorld() != world) { + return false; + } + } + + List actorKeys = AnimationDefinitions.resolveActorKeys(definition, actors, world.getRandom()); + return isDefinitionStartEligible(world, definition, actors, actorKeys, positionAnchorUuid); + } else { + return false; + } + } + + public static boolean isDefinitionStartEligible( + ServerWorld world, + @Nullable AnimationDefinitions.Definition definition, + List actorsSorted, + List actorKeys, + @Nullable UUID positionAnchorUuid + ) { + if (world != null && definition != null && actorsSorted != null && !actorsSorted.isEmpty()) { + List actors = new ArrayList<>(actorsSorted.size()); + actors.addAll(actorsSorted); + actors.sort(Comparator.comparingInt(Entity::getId)); + + for (Entity actor : actors) { + if (actor == null || actor.getEntityWorld() != world) { + return false; + } + } + + if (actorKeys == null || actorKeys.size() != actors.size()) { + return false; + } + + if (!AnimationDefinitions.resolveAllActorKeyAssignments(definition, actors).contains(List.copyOf(actorKeys))) { + return false; + } + + List actorUuids = actors.stream().map(Entity::getUuid).toList(); + UUID effectiveAnchorUuid = resolveEffectivePositionAnchorUuid(world, definition.id(), actorUuids, actorKeys, positionAnchorUuid); + return checkStartRequirements(world, definition, actorUuids, effectiveAnchorUuid).valid(); + } else { + return false; + } + } + + public static void setAnimationStartProtection(@Nullable ServerPlayerEntity player, int ticks) { + if (player != null) { + if (player.getEntityWorld() instanceof ServerWorld world) { + if (ticks <= 0) { + ANIMATION_START_PROTECTION.remove(player.getUuid()); + } else { + long until = world.getTime() + ticks; + ANIMATION_START_PROTECTION.put(player.getUuid(), new ServerAnimationController.AnimationStartProtection(world.getRegistryKey(), until)); + } + } + } + } + + public static void clearAnimationStartProtection(@Nullable ServerPlayerEntity player) { + if (player != null) { + ANIMATION_START_PROTECTION.remove(player.getUuid()); + } + } + + public static boolean hasAnimationStartProtection(@Nullable ServerWorld world, @Nullable ServerPlayerEntity player) { + if (world != null && player != null) { + ServerAnimationController.AnimationStartProtection protection = ANIMATION_START_PROTECTION.get(player.getUuid()); + if (protection == null) { + return false; + } else if (!protection.worldKey.equals(world.getRegistryKey())) { + ANIMATION_START_PROTECTION.remove(player.getUuid()); + return false; + } else if (world.getTime() >= protection.untilTick) { + ANIMATION_START_PROTECTION.remove(player.getUuid()); + return false; + } else { + return true; + } + } else { + return false; + } + } + + public static boolean enqueueForPlayerWithContext( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + Identifier animationId, + List actors, + int insertIndex, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + @Nullable AnimationStartContext startContext, + boolean forceChat + ) { + if (world != null && animationId != null && actors != null && !actors.isEmpty()) { + UUID playerUuid = inferSinglePlayerUuid(world, actors); + if (playerUuid == null) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("queue_enqueue_failed_exactly_one_player"), forceChat); + return false; + } + + ServerAnimationController.PlayerQueueState queue = PLAYER_QUEUES_BY_WORLD.computeIfAbsent(world.getRegistryKey(), k -> new HashMap<>()) + .computeIfAbsent(playerUuid, ServerAnimationController.PlayerQueueState::new); + if (queue.entries.size() >= 8) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("queue_enqueue_failed_full", 8), forceChat); + return false; + } + + List actorUuids = new ArrayList<>(); + List nonPlayers = new ArrayList<>(); + + for (Entity actor : actors) { + if (actor != null && actor.getEntityWorld() == world) { + UUID uuid = actor.getUuid(); + if (actor instanceof ServerPlayerEntity) { + if (!uuid.equals(playerUuid)) { + continue; + } + } else { + nonPlayers.add(uuid); + } + + actorUuids.add(uuid); + } + } + + if (!actorUuids.contains(playerUuid)) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("queue_enqueue_failed_player_missing"), forceChat); + return false; + } + + Map mobOwner = QUEUED_MOB_TO_PLAYER_BY_WORLD.computeIfAbsent(world.getRegistryKey(), k -> new HashMap<>()); + + for (UUID mobUuid : nonPlayers) { + UUID existingOwner = mobOwner.get(mobUuid); + if (existingOwner != null) { + sendOrLogDebug( + world, + requester, + NonDebugChatCategory.WARNING, + dbg("queue_enqueue_denied_mob_already_queued", describeActorForDebug(world, mobUuid)), + forceChat + ); + return false; + } + } + + UUID entryId = UUID.randomUUID(); + ServerAnimationController.QueuedEntry entry = new ServerAnimationController.QueuedEntry( + entryId, + animationId, + List.copyOf(actorUuids), + damageBehavior == null ? NonAnimationDamageBehavior.IGNORE_DAMAGE : damageBehavior, + ignoreAttackers, + sanitizeStartContext(startContext) + ); + insertQueueEntry(queue.entries, entry, insertIndex); + captureDeferredQueueContextFromActiveInstance(world, playerUuid); + + for (UUID mobUuid : nonPlayers) { + mobOwner.put(mobUuid, playerUuid); + } + + sendOrLogDebug( + world, requester, NonDebugChatCategory.INFO, dbg("queued_at_position", animationId, describeQueuePosition(queue.entries, entryId)), forceChat + ); + return true; + } else { + return false; + } + } + + public static void clearPlayerQueue(ServerWorld world, @Nullable ServerPlayerEntity player) { + if (world != null && player != null) { + clearPlayerQueueByUuid(world, player.getUuid(), NonDebugChatCategory.INFO, dbg("queue_cleared_for_player")); + } + } + + private static void stopActiveInstancesForDisconnectingPlayer(ServerWorld world, UUID playerUuid) { + if (world != null && playerUuid != null && !ACTIVE_INSTANCES.isEmpty()) { + List instanceIds = new ArrayList<>(); + + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world && inst.actorUuidSet.contains(playerUuid)) { + instanceIds.add(entry.getKey()); + } + } + + for (UUID instanceId : instanceIds) { + stopInstance(world, instanceId, true, ServerAnimationController.StopReason.EXTERNAL_STOP); + } + } + } + + @Nullable + private static ServerAnimationController.EligibleDefinition selectEligibleDefinition( + ServerWorld world, List actorsSorted, Set requiredAnimationTags, @Nullable UUID positionAnchorUuid + ) { + if (world != null && actorsSorted != null && !actorsSorted.isEmpty()) { + AnimationDefinitions.MatchResult match = AnimationDefinitions.match(actorsSorted, requiredAnimationTags); + List candidates = match.candidatesSorted(); + if (candidates != null && !candidates.isEmpty()) { + List eligible = new ArrayList<>(); + List actorUuids = actorsSorted.stream().map(Entity::getUuid).toList(); + + for (AnimationDefinitions.Definition definition : candidates) { + List actorKeys = AnimationDefinitions.resolveActorKeys(definition, actorsSorted, world.getRandom()); + UUID effectiveAnchorUuid = resolveEffectivePositionAnchorUuid(world, definition.id(), actorUuids, actorKeys, positionAnchorUuid); + ServerAnimationController.RequirementCheck check = checkStartRequirements(world, definition, actorUuids, effectiveAnchorUuid); + if (check.valid()) { + eligible.add(new ServerAnimationController.EligibleDefinition(definition, actorKeys, check.blockPlacement())); + } + } + + return eligible.isEmpty() ? null : pickWeightedEligible(eligible, world.getRandom()); + } else { + return null; + } + } else { + return null; + } + } + + private static ServerAnimationController.RequirementCheck checkStartRequirements( + ServerWorld world, @Nullable AnimationDefinitions.Definition definition, List actorUuids, @Nullable UUID effectiveAnchorUuid + ) { + if (definition == null) { + return new ServerAnimationController.RequirementCheck(false, null); + } else { + AnimationDefinitions.BlockRequirements blockRequirements = definition.blockRequirements(); + AnimationDefinitions.WaterRequirement waterRequirement = definition.waterRequirement(); + if (blockRequirements != null && waterRequirement != AnimationDefinitions.WaterRequirement.NONE) { + return new ServerAnimationController.RequirementCheck(false, null); + } else if (blockRequirements != null) { + Entity anchor = pickAnchorEntity(world, actorUuids, effectiveAnchorUuid); + ServerAnimationController.BlockPlacement placement = anchor == null ? null : findBlockPlacement(world, anchor, blockRequirements); + return new ServerAnimationController.RequirementCheck(placement != null, placement); + } else { + return new ServerAnimationController.RequirementCheck(waterRequirementMet(world, definition, actorUuids), null); + } + } + } + + private static ServerAnimationController.EligibleDefinition pickWeightedEligible( + List definitions, Random random + ) { + return WeightedPicker.pick(definitions, eligible -> eligible.definition().weight(), random.nextDouble()); + } + + public static boolean advanceStage(ServerWorld world, UUID instanceId) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst != null && inst.world == world) { + int currentIndex = inst.stageTracker == null ? 0 : inst.stageTracker.stageIndex; + if (inst.stages != null && !inst.stages.isEmpty()) { + if (currentIndex + 1 >= inst.stages.size()) { + return stopInstance(world, instanceId, true, ServerAnimationController.StopReason.NATURAL_END); + } + + int targetIndex = currentIndex + 1; + return advanceToStageIndex(world, instanceId, inst, targetIndex); + } else { + return false; + } + } else { + return false; + } + } + + public static boolean advanceToStage(ServerWorld world, UUID instanceId, int stageNumber) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst == null || inst.world != world) { + return false; + } else { + return stageNumber <= 0 ? false : advanceToStageIndex(world, instanceId, inst, stageNumber - 1); + } + } + + private static boolean advanceToStageIndex(ServerWorld world, UUID instanceId, ServerAnimationController.ActiveInstance inst, int targetIndex) { + if (inst == null || inst.world != world || inst.stages == null || inst.stages.isEmpty()) { + return false; + } + + if (targetIndex >= 0 && targetIndex < inst.stages.size()) { + long advanceTick = world.getTime(); + if (inst.stageTracker != null) { + inst.stageTracker.stageIndex = targetIndex; + inst.stageTracker.stageStartTick = advanceTick; + inst.stageTracker.progressEndTickOverride = -1L; + } + + inst.speed = clampSpeed(resolveStageSpeed(inst.stages, inst.stageTracker == null ? 0 : inst.stageTracker.stageIndex)); + long stageEndTick = resolveStageEndTick(inst); + AdvanceAnimationStageS2CPayload payload = new AdvanceAnimationStageS2CPayload(instanceId, advanceTick, stageEndTick, targetIndex); + Set targets = computeInstanceBroadcastTargets(world, null, inst); + rememberInstanceSubscribers(inst, targets); + + for (ServerPlayerEntity p : targets) { + ServerPlayNetworking.send(p, payload); + ServerPlayNetworking.send(p, new AnimationSpeedUpdateS2CPayload(instanceId, inst.speed, advanceTick, stageEndTick)); + } + + ((AnimationLifecycleEvents.StageAdvance)AnimationLifecycleEvents.STAGE_ADVANCE.invoker()) + .onStageAdvance(new AnimationLifecycleEvents.StageChangedContext(world, snapshot(instanceId, inst), resolveCurrentStage(inst), advanceTick)); + return true; + } else { + return false; + } + } + + public static boolean stopInstance(ServerWorld world, UUID instanceId) { + return stopInstance(world, instanceId, true, ServerAnimationController.StopReason.EXTERNAL_STOP); + } + + public static void adjustSpeed(ServerWorld world, UUID instanceId, double multiplier, @Nullable ServerPlayerEntity requester) { + adjustSpeed(world, instanceId, multiplier, requester, requester != null); + } + + public static void adjustSpeedSilently(ServerWorld world, UUID instanceId, double multiplier) { + adjustSpeed(world, instanceId, multiplier, null, false); + } + + private static void adjustSpeed(ServerWorld world, UUID instanceId, double multiplier, @Nullable ServerPlayerEntity requester, boolean notify) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst != null && inst.world == world) { + double clampedMultiplier = sanitizeMultiplier(multiplier); + double newSpeed = clampSpeed(inst.speed * clampedMultiplier); + double oldSpeed = inst.speed; + if (Math.abs(newSpeed - oldSpeed) < 1.0E-4) { + if (notify) { + sendOrLogDebug(world, requester, NonDebugChatCategory.ALWAYS, dbg("speed_unchanged"), true); + } + } else { + long now = world.getTime(); + AnimationStageInfo currentStage = resolveCurrentStage(inst); + long duration = Math.max(0L, currentStage.lengthTicks()); + if (inst.stageTracker != null) { + if (duration > 0L) { + long elapsed = Math.max(0L, now - inst.stageTracker.stageStartTick); + double normalizedProgress = elapsed * oldSpeed; + long adjustedStart = now - Math.round(normalizedProgress / newSpeed); + inst.stageTracker.stageStartTick = Math.max(inst.startTick, adjustedStart); + } + + inst.stageTracker.progressEndTickOverride = -1L; + } + + inst.speed = newSpeed; + long stageStartTick = inst.stageTracker == null ? inst.startTick : inst.stageTracker.stageStartTick; + long stageEndTick = resolveStageEndTick(inst); + AnimationSpeedUpdateS2CPayload payload = new AnimationSpeedUpdateS2CPayload(instanceId, newSpeed, stageStartTick, stageEndTick); + Set targets = computeInstanceBroadcastTargets(world, requester, inst); + rememberInstanceSubscribers(inst, targets); + + for (ServerPlayerEntity p : targets) { + ServerPlayNetworking.send(p, payload); + } + + if (notify) { + sendOrLogDebug(world, requester, NonDebugChatCategory.ALWAYS, dbg("speed_set", String.format(Locale.ROOT, "%.2fx", newSpeed)), true); + } + } + } else { + if (notify) { + sendOrLogDebug(world, requester, NonDebugChatCategory.ALWAYS, dbg("no_active_instance_to_adjust"), true); + } + } + } + + public static boolean setCurrentStageProgressDuration(ServerWorld world, UUID instanceId, long durationTicks) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst != null && inst.world == world && inst.stageTracker != null && durationTicks > 0L) { + long stageStartTick = inst.stageTracker.stageStartTick; + inst.stageTracker.progressEndTickOverride = stageStartTick + Math.max(1L, durationTicks); + AnimationSpeedUpdateS2CPayload payload = new AnimationSpeedUpdateS2CPayload( + instanceId, inst.speed, stageStartTick, inst.stageTracker.progressEndTickOverride + ); + Set targets = computeInstanceBroadcastTargets(world, null, inst); + rememberInstanceSubscribers(inst, targets); + + for (ServerPlayerEntity player : targets) { + ServerPlayNetworking.send(player, payload); + } + + return true; + } else { + return false; + } + } + + private static void stopActiveInstancesForDeadActor(ServerWorld world, LivingEntity actor) { + if (world != null && actor != null) { + UUID actorUuid = actor.getUuid(); + stopActiveInstancesForDeadActor(world, actorUuid); + clearRecentNoRestoreTransition(world, actorUuid); + ANIMATION_START_PROTECTION.remove(actorUuid); + if (actor instanceof ServerPlayerEntity) { + clearPlayerQueueByUuid(world, actorUuid, null, null); + } + } + } + + private static void stopActiveInstancesForDeadActor(ServerWorld world, UUID actorUuid) { + if (world != null && actorUuid != null && !ACTIVE_INSTANCES.isEmpty()) { + List instanceIds = new ArrayList<>(); + + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world && inst.actorUuidSet.contains(actorUuid)) { + instanceIds.add(entry.getKey()); + } + } + + for (UUID instanceId : instanceIds) { + stopInstance(world, instanceId, true, ServerAnimationController.StopReason.ACTOR_DEATH, Set.of(actorUuid)); + } + } + } + + public static boolean stopInstance(ServerWorld world, UUID instanceId, boolean restoreTransforms) { + return stopInstance(world, instanceId, restoreTransforms, ServerAnimationController.StopReason.EXTERNAL_STOP); + } + + public static boolean stopInstance(ServerWorld world, UUID instanceId, boolean restoreTransforms, ServerAnimationController.StopReason reason) { + return stopInstance(world, instanceId, restoreTransforms, reason, null); + } + + private static boolean stopInstance( + ServerWorld world, UUID instanceId, boolean restoreTransforms, ServerAnimationController.StopReason reason, @Nullable Set skipRestoreActors + ) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst != null && inst.world == world) { + long stopTick = world.getTime() + 1L; + boolean chained = false; + if (reason == ServerAnimationController.StopReason.NATURAL_END && inst.playerUuid != null) { + chained = startNextQueuedForPlayerDeferringRestore(world, inst, instanceId); + } + + StopAnimationS2CPayload payload = new StopAnimationS2CPayload(instanceId, stopTick); + + for (ServerPlayerEntity p : computeInstanceBroadcastTargets(world, null, inst)) { + ServerPlayNetworking.send(p, payload); + } + + fireStopEvent(instanceId, inst); + if (restoreTransforms) { + Set effectiveSkipActors = skipRestoreActors; + if (chained && inst.playerUuid != null) { + if (effectiveSkipActors != null && !effectiveSkipActors.isEmpty()) { + Set combined = new HashSet<>(effectiveSkipActors); + combined.add(inst.playerUuid); + effectiveSkipActors = combined; + } else { + effectiveSkipActors = Set.of(inst.playerUuid); + } + } + + restoreTransforms(inst, effectiveSkipActors); + } else { + cacheNoRestoreTransition(world, inst); + } + + restoreAiForActors(inst.world, inst.actorUuids); + releasePlayerLocks(inst.world, inst.actorUuids); + processPendingAiRestores(inst.world); + removeActiveInstance(instanceId); + if (reason == ServerAnimationController.StopReason.NATURAL_END) { + if (chained) { + sendOrLogDebug(world, null, NonDebugChatCategory.INFO, dbg("queue_continued_for_player"), false); + } + } else { + handleQueueOnStop(world, inst, reason); + } + + return true; + } else { + return false; + } + } + + public static boolean shouldAllowActorDamage(ServerWorld world, LivingEntity actor, DamageSource source) { + UUID actorUuid = actor.getUuid(); + long now = world.getTime(); + if (source != null && source.isOf(DamageTypes.IN_WALL)) { + Map grace = IN_WALL_GRACE_BY_WORLD.get(world.getRegistryKey()); + if (grace != null) { + Long until = grace.get(actorUuid); + if (until != null) { + if (now <= until) { + return false; + } + + grace.remove(actorUuid); + } + } + + if (ACTIVE_INSTANCES.isEmpty()) { + return true; + } + + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst.world == world && now >= inst.startTick && inst.actorUuidSet.contains(actorUuid)) { + return false; + } + } + } + + if (source != null && source.isOf(DamageTypes.DROWN) && isUnderwaterBreathingProtected(world, actorUuid)) { + return false; + } + + if (ACTIVE_INSTANCES.isEmpty()) { + return true; + } + + for (Entry entry : List.copyOf(ACTIVE_INSTANCES.entrySet())) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world && now >= inst.startTick && inst.actorUuidSet.contains(actorUuid)) { + if (inst.ignoreAttackers && source != null && source.getAttacker() instanceof MobEntity) { + return false; + } + + if (inst.damageBehavior == NonAnimationDamageBehavior.BLOCK_DAMAGE) { + return false; + } + } + } + + return true; + } + + private static void stopDamageSensitiveInstances(ServerWorld world, LivingEntity actor) { + if (!ACTIVE_INSTANCES.isEmpty()) { + UUID actorUuid = actor.getUuid(); + long now = world.getTime(); + List stopInstances = null; + + for (Entry entry : List.copyOf(ACTIVE_INSTANCES.entrySet())) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world + && now >= inst.startTick + && inst.actorUuidSet.contains(actorUuid) + && inst.damageBehavior == NonAnimationDamageBehavior.STOP_ON_DAMAGE) { + if (stopInstances == null) { + stopInstances = new ArrayList<>(); + } + + stopInstances.add(entry.getKey()); + } + } + + if (stopInstances != null) { + for (UUID id : stopInstances) { + stopInstance(world, id, true, ServerAnimationController.StopReason.DAMAGE_STOP); + } + } + } + } + + public static boolean shouldIgnoreAttackers(ServerWorld world, LivingEntity actor) { + if (ACTIVE_INSTANCES.isEmpty()) { + return false; + } + + UUID actorUuid = actor.getUuid(); + + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst.world == world && inst.ignoreAttackers && inst.actorUuidSet.contains(actorUuid)) { + return true; + } + } + + return false; + } + + private static boolean isUnderwaterBreathingProtected(ServerWorld world, UUID actorUuid) { + if (world != null && actorUuid != null && !ACTIVE_INSTANCES.isEmpty()) { + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst.world == world && inst.underwaterBreathing && inst.actorUuidSet.contains(actorUuid)) { + return true; + } + } + + return false; + } else { + return false; + } + } + + private static ServerAnimationController.ActiveInstance findActiveInstanceForActors(ServerWorld world, List actorUuids) { + if (actorUuids != null && !actorUuids.isEmpty()) { + if (ACTIVE_INSTANCES.isEmpty()) { + return null; + } + + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst.world == world) { + for (UUID uuid : actorUuids) { + if (inst.actorUuidSet.contains(uuid)) { + return inst; + } + } + } + } + + return null; + } else { + return null; + } + } + + public static void stopInstanceAndBroadcast(ServerPlayerEntity requester, UUID instanceId) { + if (requester.getEntityWorld() instanceof ServerWorld requesterWorld) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + if (inst == null) { + sendOrLogDebug(requesterWorld, requester, NonDebugChatCategory.ALWAYS, dbg("no_such_active_instance"), true); + } else if (inst.world != requesterWorld) { + sendOrLogDebug(requesterWorld, requester, NonDebugChatCategory.ALWAYS, dbg("instance_in_different_world"), true); + } else { + long now = requesterWorld.getTime(); + long stopTick = now + 1L; + StopAnimationS2CPayload payload = new StopAnimationS2CPayload(instanceId, stopTick); + + for (ServerPlayerEntity p : computeInstanceBroadcastTargets(requesterWorld, requester, inst)) { + ServerPlayNetworking.send(p, payload); + } + + boolean chained = false; + if (inst.playerUuid != null) { + chained = startNextQueuedForPlayer(requesterWorld, inst.playerUuid, instanceId, resolveTransitionYaw(inst)); + } + + fireStopEvent(instanceId, inst); + restoreTransforms(inst); + restoreAiForActors(inst.world, inst.actorUuids); + releasePlayerLocks(inst.world, inst.actorUuids); + processPendingAiRestores(inst.world); + removeActiveInstance(instanceId); + sendOrLogDebug(requesterWorld, requester, NonDebugChatCategory.INFO, dbg("broadcasting_stop"), false); + if (chained) { + sendOrLogDebug(requesterWorld, requester, NonDebugChatCategory.INFO, dbg("queue_continued_for_player"), false); + } + + AnimationEngine.logRuntime( + "Broadcast STOP: instance={} stopTick={} actors={} requester={}", instanceId, stopTick, inst.actorUuids.size(), requester.getName().getString() + ); + } + } + } + + public static void clearAllInstancesInWorld(ServerWorld world) { + List toRemove = new ArrayList<>(); + + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world) { + fireStopEvent(entry.getKey(), inst); + restoreTransforms(inst); + restoreAiForActors(world, inst.actorUuids); + releasePlayerLocks(world, inst.actorUuids); + toRemove.add(entry.getKey()); + handleQueueOnStop(world, inst, ServerAnimationController.StopReason.STOP_ALL); + } + } + + for (UUID id : toRemove) { + removeActiveInstance(id); + } + + Map queues = PLAYER_QUEUES_BY_WORLD.remove(world.getRegistryKey()); + if (queues != null) { + Map byMob = QUEUED_MOB_TO_PLAYER_BY_WORLD.get(world.getRegistryKey()); + if (byMob != null) { + byMob.clear(); + } + + DEFERRED_QUEUE_CONTEXT_BY_WORLD.remove(world.getRegistryKey()); + + for (UUID playerUuid : queues.keySet()) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(playerUuid); + sendOrLogDebug(world, player, NonDebugChatCategory.INFO, dbg("queue_cleared"), false); + } + } + + Map grace = IN_WALL_GRACE_BY_WORLD.get(world.getRegistryKey()); + if (grace != null) { + grace.clear(); + } + + processPendingAiRestores(world); + clearAllPlayerLocksInWorld(world); + RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.remove(world.getRegistryKey()); + } + + private static void tickWorld(ServerWorld world) { + cleanupInWallGrace(world, world.getTime()); + cleanupNoRestoreTransitions(world, world.getTime()); + tickActiveInstances(world); + tickNoAiCleanup(world); + tickPlayerQueues(world); + } + + private static void cacheNoRestoreTransition(ServerWorld world, ServerAnimationController.ActiveInstance inst) { + if (world != null && inst != null && inst.sharedTransform != null && inst.actorUuidSet != null && !inst.actorUuidSet.isEmpty()) { + long expiresTick = world.getTime() + 20L; + ServerAnimationController.RecentNoRestoreTransition transition = new ServerAnimationController.RecentNoRestoreTransition( + expiresTick, inst.sharedTransform, inst.blockPlacement + ); + Map byActor = RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.computeIfAbsent( + world.getRegistryKey(), ignored -> new HashMap<>() + ); + + for (UUID actorUuid : inst.actorUuidSet) { + if (actorUuid != null) { + byActor.put(actorUuid, transition); + } + } + } + } + + @Nullable + private static ServerAnimationController.RecentNoRestoreTransition consumeNoRestoreTransition( + ServerWorld world, List actorUuids, AnimationStartContext startContext + ) { + if (world == null || actorUuids == null || actorUuids.isEmpty() || startContext == null) { + return null; + } + + if (!startContext.isJoinReplacement()) { + return null; + } + + Map byActor = RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.get(world.getRegistryKey()); + if (byActor != null && !byActor.isEmpty()) { + long now = world.getTime(); + ServerAnimationController.RecentNoRestoreTransition selected = null; + + for (UUID actorUuid : actorUuids) { + ServerAnimationController.RecentNoRestoreTransition candidate = byActor.get(actorUuid); + if (candidate != null) { + if (candidate.expiresTick() >= now) { + selected = candidate; + break; + } + + byActor.remove(actorUuid); + } + } + + for (UUID actorUuid : actorUuids) { + byActor.remove(actorUuid); + } + + if (byActor.isEmpty()) { + RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.remove(world.getRegistryKey()); + } + + return selected; + } else { + return null; + } + } + + private static void cleanupNoRestoreTransitions(ServerWorld world, long now) { + Map byActor = RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.get(world.getRegistryKey()); + if (byActor != null && !byActor.isEmpty()) { + byActor.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue().expiresTick() < now); + if (byActor.isEmpty()) { + RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + + private static void clearRecentNoRestoreTransition(ServerWorld world, UUID actorUuid) { + if (world != null && actorUuid != null) { + Map byActor = RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.get(world.getRegistryKey()); + if (byActor != null) { + byActor.remove(actorUuid); + if (byActor.isEmpty()) { + RECENT_NO_RESTORE_TRANSITIONS_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + } + + private static boolean shouldBlockStart(ServerWorld world, List actorUuids) { + if (actorUuids != null && !actorUuids.isEmpty()) { + for (UUID uuid : actorUuids) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(uuid); + if (player != null && player.getEntityWorld() == world && hasAnimationStartProtection(world, player)) { + return true; + } + } + + return false; + } else { + return false; + } + } + + private static Text dbg(String key, Object... args) { + return Text.translatable("debug.needsofnature.animation." + key, sanitizeTranslatableArgs(args)); + } + + private static Object[] sanitizeTranslatableArgs(Object... args) { + if (args != null && args.length != 0) { + Object[] safe = new Object[args.length]; + + for (int i = 0; i < args.length; i++) { + Object arg = args[i]; + if (arg instanceof Identifier id) { + safe[i] = id.toString(); + } else { + safe[i] = !(arg instanceof Text) && arg != null ? String.valueOf(arg) : arg; + } + } + + return safe; + } else { + return new Object[0]; + } + } + + private static void sendOrLogDebug( + ServerWorld world, @Nullable ServerPlayerEntity requester, NonDebugChatCategory category, String message, boolean forceChat + ) { + sendOrLogDebug(world, requester, category, Text.literal(message), forceChat); + } + + private static void sendOrLogDebug(ServerWorld world, @Nullable ServerPlayerEntity requester, NonDebugChatCategory category, Text message, boolean forceChat) { + AnimationEngine.logRuntime("{}", message.getString()); + if (requester != null) { + if (AnimationEngine.shouldSendDebugChat(requester, category, forceChat)) { + AnimationEngine.sendDebugChat(requester, category, message, forceChat); + } + } else if (world != null) { + for (ServerPlayerEntity player : world.getPlayers()) { + if (AnimationEngine.shouldSendDebugChat(player, category, forceChat)) { + AnimationEngine.sendDebugChat(player, category, message, forceChat); + } + } + } + } + + private static void tickActiveInstances(ServerWorld world) { + long now = world.getTime(); + List toRemove = new ArrayList<>(); + Set actorsToFreeze = new HashSet<>(); + Set activePlayerUuids = new HashSet<>(); + Map playersToLock = new HashMap<>(); + Set underwaterPlayers = new HashSet<>(); + List stageAdvances = new ArrayList<>(); + Map deadActors = new LinkedHashMap<>(); + + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst.world == world) { + LivingEntity deadActor = findDeadLoadedActor(inst); + if (deadActor != null) { + deadActors.putIfAbsent(deadActor.getUuid(), deadActor); + } + } + } + + for (LivingEntity deadActor : deadActors.values()) { + stopActiveInstancesForDeadActor(world, deadActor); + } + + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world && !maybeAdvanceStages(world, entry.getKey(), inst, now, stageAdvances, toRemove)) { + actorsToFreeze.addAll(inst.actorUuidSet); + if (inst.playerUuid != null) { + activePlayerUuids.add(inst.playerUuid); + } + + if (inst.sharedTransform != null) { + for (UUID uuid : inst.actorUuidSet) { + Entity e = world.getEntity(uuid); + if (e instanceof ServerPlayerEntity) { + playersToLock.put(uuid, inst.sharedTransform); + if (inst.underwaterBreathing) { + underwaterPlayers.add(uuid); + } + } + } + } + } + } + + for (ServerAnimationController.StageAdvance adv : stageAdvances) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(adv.instanceId()); + if (inst != null && inst.world == world) { + AdvanceAnimationStageS2CPayload payload = new AdvanceAnimationStageS2CPayload( + adv.instanceId(), adv.advanceTick(), adv.stageEndTick(), adv.stageIndex() + ); + Set targets = computeInstanceBroadcastTargets(world, null, inst); + rememberInstanceSubscribers(inst, targets); + + for (ServerPlayerEntity p : targets) { + ServerPlayNetworking.send(p, payload); + ServerPlayNetworking.send(p, new AnimationSpeedUpdateS2CPayload(adv.instanceId(), inst.speed, adv.advanceTick(), adv.stageEndTick())); + } + + ((AnimationLifecycleEvents.StageAdvance)AnimationLifecycleEvents.STAGE_ADVANCE.invoker()) + .onStageAdvance( + new AnimationLifecycleEvents.StageChangedContext(world, snapshot(adv.instanceId(), inst), resolveCurrentStage(inst), adv.advanceTick()) + ); + } + } + + freezeActiveActors(world, actorsToFreeze); + lockActivePlayers(world, playersToLock, underwaterPlayers); + nudgeNonActiveMobsAwayFromAnimatedPlayers(world, activePlayerUuids, now); + cleanupStalePlayerLocks(world, playersToLock.keySet()); + + for (UUID id : toRemove) { + removeActiveInstance(id); + } + + processPendingAiRestores(world); + } + + @Nullable + private static LivingEntity findDeadLoadedActor(ServerAnimationController.ActiveInstance inst) { + for (UUID actorUuid : inst.actorUuidSet) { + if (inst.world.getEntity(actorUuid) instanceof LivingEntity living && (!living.isAlive() || living.isRemoved())) { + return living; + } + } + + return null; + } + + @Nullable + private static UUID startAndBroadcast( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext, + boolean forceChat, + @Nullable Float transitionYaw + ) { + return startAndBroadcast( + world, requester, animationId, actorUuids, actorKeys, stages, damageBehavior, ignoreAttackers, startContext, forceChat, null, null, transitionYaw + ); + } + + @Nullable + private static UUID startAndBroadcast( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext, + boolean forceChat, + @Nullable UUID preferredAnchorUuid, + @Nullable Float transitionYaw + ) { + return startAndBroadcast( + world, + requester, + animationId, + actorUuids, + actorKeys, + stages, + damageBehavior, + ignoreAttackers, + startContext, + forceChat, + preferredAnchorUuid, + null, + transitionYaw + ); + } + + @Nullable + private static UUID startAndBroadcast( + ServerWorld world, + @Nullable ServerPlayerEntity requester, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext, + boolean forceChat, + @Nullable UUID preferredAnchorUuid, + @Nullable ServerAnimationController.BlockPlacement cachedBlockPlacement, + @Nullable Float transitionYaw + ) { + long startTick = world.getTime() + 1L; + UUID instanceId = UUID.randomUUID(); + Map originalTransforms = captureOriginalTransforms(world, actorUuids); + AnimationStartContext safeContext = sanitizeStartContext(startContext); + UUID effectivePositionAnchorUuid = resolveEffectivePositionAnchorUuid(world, animationId, actorUuids, actorKeys, preferredAnchorUuid); + ServerAnimationController.RecentNoRestoreTransition joinTransition = consumeNoRestoreTransition(world, actorUuids, safeContext); + Float effectiveTransitionYaw = transitionYaw; + if (effectiveTransitionYaw == null && joinTransition != null) { + effectiveTransitionYaw = joinTransition.sharedTransform().yaw(); + } + + ServerAnimationController.SharedTransform sharedTransform = joinTransition != null + ? joinTransition.sharedTransform() + : pickSharedTransform(actorUuids, originalTransforms, effectivePositionAnchorUuid); + AnimationDefinitions.Definition def = AnimationDefinitions.getDefinition(animationId); + AnimationDefinitions.AnimationPackInfo packInfo = def == null ? null : def.packInfo(); + List> modelRootsByActor = resolveModelRootsByActor(world, actorUuids, packInfo); + AnimationDefinitions.BlockRequirements blockRequirements = def == null ? null : def.blockRequirements(); + AnimationDefinitions.WaterRequirement waterRequirement = def == null ? AnimationDefinitions.WaterRequirement.NONE : def.waterRequirement(); + ServerAnimationController.BlockPlacement placement = cachedBlockPlacement != null + ? cachedBlockPlacement + : (joinTransition == null ? null : joinTransition.blockPlacement()); + boolean lockOrientation = false; + float lockedYaw = 0.0F; + float lockedHeadYaw = 0.0F; + float lockedPitch = 0.0F; + if (blockRequirements != null && waterRequirement != AnimationDefinitions.WaterRequirement.NONE) { + sendOrLogDebug(world, requester, NonDebugChatCategory.SETUP, dbg("start_cancelled_block_and_water_requirement"), forceChat); + return null; + } + + if (blockRequirements != null) { + Entity anchor = pickAnchorEntity(world, actorUuids, effectivePositionAnchorUuid); + if (placement == null) { + placement = anchor == null ? null : findBlockPlacement(world, anchor, blockRequirements); + } + + if (placement == null) { + sendOrLogDebug( + world, requester, NonDebugChatCategory.WARNING, blockRequirementFailureMessage(blockRequirements, animationId, world, actorUuids), forceChat + ); + return null; + } + + sharedTransform = placement.sharedTransform(); + lockOrientation = true; + lockedYaw = sharedTransform.yaw(); + lockedHeadYaw = sharedTransform.headYaw(); + lockedPitch = sharedTransform.pitch(); + } else if (waterRequirement != AnimationDefinitions.WaterRequirement.NONE) { + Entity waterAnchor = findWaterAnchor(world, actorUuids, waterRequirement); + if (waterAnchor == null) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("start_cancelled_water_requirement_not_met"), forceChat); + return null; + } + + ServerAnimationController.SharedTransform base = sharedTransform; + sharedTransform = new ServerAnimationController.SharedTransform(waterAnchor.getEntityPos(), base.yaw(), base.pitch(), base.headYaw(), base.bodyYaw()); + } else { + if (anyActorOnWaterFooting(world, actorUuids)) { + sendOrLogDebug(world, requester, NonDebugChatCategory.WARNING, dbg("start_cancelled_not_allowed_in_water"), forceChat); + return null; + } + + UUID queuedPlayerUuid = resolvePlayerUuid(world, actorUuids); + ServerAnimationController.SharedTransform groundTransform = resolveQueuedPlacementTransform(world, queuedPlayerUuid, sharedTransform, safeContext); + if (groundTransform == null) { + groundTransform = findSafeGroundPlacement(world, sharedTransform); + } + + if (groundTransform == null) { + ServerAnimationController.SharedTransform dismountTransform = findMountedPlayerDismountTransform(world, actorUuids, sharedTransform); + if (dismountTransform != null) { + groundTransform = findSafeGroundPlacement(world, dismountTransform); + } + } + + if (groundTransform == null) { + sendOrLogDebug( + world, + requester, + NonDebugChatCategory.WARNING, + dbg("start_cancelled_not_enough_space", describeAnimationIdForDebug(animationId), describeActorsForDebug(world, actorUuids)), + forceChat + ); + return null; + } + + sharedTransform = groundTransform; + } + + if (effectivePositionAnchorUuid != null && !lockOrientation) { + lockOrientation = true; + lockedYaw = sharedTransform.yaw(); + lockedHeadYaw = sharedTransform.headYaw(); + lockedPitch = sharedTransform.pitch(); + } + + boolean preservePlayerLook = false; + if (effectiveTransitionYaw != null && blockRequirements == null) { + float yaw = MathHelper.wrapDegrees(effectiveTransitionYaw); + sharedTransform = new ServerAnimationController.SharedTransform(sharedTransform.pos(), yaw, 0.0F, yaw, yaw); + lockOrientation = true; + lockedYaw = yaw; + lockedHeadYaw = yaw; + lockedPitch = 0.0F; + preservePlayerLook = true; + } + + Set actorUuidSet = Set.copyOf(new HashSet<>(actorUuids)); + List safeActorKeys = actorKeys != null && actorKeys.size() == actorUuids.size() ? List.copyOf(actorKeys) : List.of(); + List safeStages = stages == null ? List.of() : List.copyOf(stages); + if (!((AnimationLifecycleEvents.AllowStart)AnimationLifecycleEvents.ALLOW_START.invoker()) + .allowStart( + new AnimationLifecycleEvents.AllowStartContext( + world, animationId, actorUuids, safeActorKeys, safeStages, requester, damageBehavior, ignoreAttackers, safeContext + ) + )) { + return null; + } + + double initialSpeed = clampSpeed(resolveStageSpeed(stages, 0)); + AnimationStageInfo initialStage = safeStages.isEmpty() ? new AnimationStageInfo(animationId, true, 0L, true, initialSpeed) : safeStages.getFirst(); + long initialStageEndTick = resolveStageEndTick(animationId, initialStage, startTick, initialSpeed); + Vec3d cameraOrbitTarget = placement == null ? null : placement.cameraOrbitTarget(); + StartAnimationS2CPayload payload = new StartAnimationS2CPayload( + animationId, + modelRootsByActor, + instanceId, + actorUuids, + safeActorKeys, + safeStages, + startTick, + 0, + startTick, + initialStageEndTick, + initialSpeed, + lockOrientation, + lockedYaw, + lockedHeadYaw, + lockedPitch, + cameraOrbitTarget + ); + disableAiForActors(world, actorUuids); + if (placement != null) { + markInWallGrace(world, actorUuids); + } + + applySharedTransform(world, actorUuids, sharedTransform, preservePlayerLook); + if (ignoreAttackers) { + clearAttackTargets(world, actorUuidSet); + } + + Set targets = computeBroadcastTargets(world, requester, actorUuids); + + for (ServerPlayerEntity p : targets) { + ServerPlayNetworking.send(p, payload); + } + + UUID playerUuid = resolvePlayerUuid(world, actorUuids); + originalTransforms = applyDeferredQueueOriginal(world, playerUuid, originalTransforms, safeContext); + boolean underwaterBreathing = waterRequirement == AnimationDefinitions.WaterRequirement.UNDERWATER; + ServerAnimationController.ActiveInstance active = new ServerAnimationController.ActiveInstance( + world, + animationId, + modelRootsByActor, + List.copyOf(actorUuids), + safeActorKeys, + safeStages, + startTick, + originalTransforms, + sharedTransform, + lockOrientation, + lockedYaw, + lockedHeadYaw, + lockedPitch, + damageBehavior, + ignoreAttackers, + safeContext, + underwaterBreathing, + playerUuid, + actorUuidSet, + new ServerAnimationController.StageTracker(startTick), + placement, + cameraOrbitTarget, + initialSpeed + ); + registerActiveInstance(instanceId, active); + rememberInstanceSubscribers(active, targets); + acquirePlayerLocks(world, actorUuids); + ((AnimationLifecycleEvents.Start)AnimationLifecycleEvents.START.invoker()) + .onStart(new AnimationLifecycleEvents.StartedContext(world, snapshot(instanceId, active), requester)); + sendOrLogDebug(world, requester, NonDebugChatCategory.INFO, dbg("actors_immediate_broadcast_start"), false); + String requesterName = requester == null ? "api" : requester.getName().getString(); + AnimationEngine.logRuntime( + "Broadcast START: animation={} instance={} startTick={} actors={} requester={}", + describeAnimationIdForDebug(animationId), + instanceId, + startTick, + actorUuids.size(), + requesterName + ); + return instanceId; + } + + private static Set computeBroadcastTargets(ServerWorld world, @Nullable ServerPlayerEntity requester, List actorUuids) { + Set targets = new HashSet<>(); + if (requester != null) { + targets.add(requester); + } + + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e != null) { + targets.addAll(PlayerLookup.tracking(e)); + if (e instanceof ServerPlayerEntity sp) { + targets.add(sp); + } + } else { + ServerPlayerEntity p = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(uuid); + if (p != null && p.getEntityWorld() == world) { + targets.add(p); + targets.addAll(PlayerLookup.tracking(p)); + } + } + } + + return targets; + } + + private static Set computeInstanceBroadcastTargets( + ServerWorld world, @Nullable ServerPlayerEntity requester, ServerAnimationController.ActiveInstance inst + ) { + Set targets = computeBroadcastTargets(world, requester, inst == null ? List.of() : inst.actorUuids); + if (world != null && inst != null && !inst.subscribedPlayerUuids.isEmpty()) { + for (UUID playerUuid : inst.subscribedPlayerUuids) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(playerUuid); + if (player != null && player.getEntityWorld() == world) { + targets.add(player); + } + } + + return targets; + } else { + return targets; + } + } + + private static void rememberInstanceSubscribers(ServerAnimationController.ActiveInstance inst, Set targets) { + if (inst != null && targets != null && !targets.isEmpty()) { + for (ServerPlayerEntity player : targets) { + if (player != null && player.getEntityWorld() == inst.world) { + inst.subscribedPlayerUuids.add(player.getUuid()); + } + } + } + } + + private static void removePlayerFromInstanceSubscribers(UUID playerUuid) { + if (playerUuid != null && !ACTIVE_INSTANCES.isEmpty()) { + for (ServerAnimationController.ActiveInstance inst : ACTIVE_INSTANCES.values()) { + if (inst != null) { + inst.subscribedPlayerUuids.remove(playerUuid); + } + } + } + } + + private static void fireStopEvent(UUID instanceId, ServerAnimationController.ActiveInstance inst) { + if (inst != null) { + ((AnimationLifecycleEvents.Stop)AnimationLifecycleEvents.STOP.invoker()) + .onStop(new AnimationLifecycleEvents.StoppedContext(inst.world, snapshot(instanceId, inst))); + } + } + + private static boolean allSelectedPlayersNearRequester(List actors, ServerPlayerEntity requester, double maxDistance) { + if (actors != null && requester != null) { + double maxDistanceSq = maxDistance * maxDistance; + + for (Entity actor : actors) { + if (actor instanceof PlayerEntity playerActor && playerActor.squaredDistanceTo(requester) > maxDistanceSq) { + return false; + } + } + + return true; + } else { + return false; + } + } + + private static Vec3d computeCentroid(List entities) { + double x = 0.0; + double y = 0.0; + double z = 0.0; + + for (Entity e : entities) { + Vec3d p = e.getEntityPos(); + x += p.x; + y += p.y; + z += p.z; + } + + double n = entities.size(); + return new Vec3d(x / n, y / n, z / n); + } + + private static Map captureOriginalTransforms(ServerWorld world, List actorUuids) { + Map originals = new HashMap<>(); + + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e instanceof LivingEntity living) { + Vec3d restorePos = living instanceof ServerPlayerEntity player ? resolveMountedPlayerRestorePosition(player) : null; + originals.put( + uuid, + new ServerAnimationController.OriginalTransform( + world.getRegistryKey(), living.getEntityPos(), living.getYaw(), living.getPitch(), living.getHeadYaw(), living.getBodyYaw(), restorePos + ) + ); + } else if (e != null) { + originals.put( + uuid, + new ServerAnimationController.OriginalTransform(world.getRegistryKey(), e.getEntityPos(), e.getYaw(), e.getPitch(), e.getYaw(), e.getYaw()) + ); + } + } + + return originals; + } + + @Nullable + private static UUID resolveEffectivePositionAnchorUuid( + ServerWorld world, Identifier animationId, List actorUuids, List actorKeys, @Nullable UUID preferredAnchorUuid + ) { + Entity explicitAnchor = resolveAnchorEntity(world, actorUuids, preferredAnchorUuid); + if (explicitAnchor != null) { + return explicitAnchor.getUuid(); + } + + AnimationDefinitions.Definition def = AnimationDefinitions.getDefinition(animationId); + String anchorActorKey = def == null ? null : normalizeActorKey(def.positionAnchorActor()); + if (anchorActorKey == null) { + return null; + } + + if (actorKeys != null && actorKeys.size() == actorUuids.size()) { + for (int i = 0; i < actorUuids.size(); i++) { + if (anchorActorKey.equals(normalizeActorKey(actorKeys.get(i)))) { + Entity anchor = resolveAnchorEntity(world, actorUuids, actorUuids.get(i)); + return anchor == null ? null : anchor.getUuid(); + } + } + + return null; + } else { + return null; + } + } + + @Nullable + private static String normalizeActorKey(@Nullable String actorKey) { + if (actorKey == null) { + return null; + } + + String value = actorKey.trim(); + return value.isEmpty() ? null : value.toLowerCase(Locale.ROOT); + } + + @Nullable + private static Entity resolveAnchorEntity(ServerWorld world, List actorUuids, @Nullable UUID anchorUuid) { + if (world == null || actorUuids == null || actorUuids.isEmpty() || anchorUuid == null) { + return null; + } + + if (!actorUuids.contains(anchorUuid)) { + return null; + } + + Entity entity = world.getEntity(anchorUuid); + if (entity != null) { + return entity; + } + + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(anchorUuid); + return player != null && player.getEntityWorld() == world ? player : null; + } + + private static ServerAnimationController.SharedTransform pickSharedTransform( + List actorUuids, Map originals, @Nullable UUID anchorUuid + ) { + if (anchorUuid != null) { + ServerAnimationController.OriginalTransform anchored = originals.get(anchorUuid); + if (anchored != null) { + return new ServerAnimationController.SharedTransform(anchored.pos(), anchored.yaw(), anchored.pitch(), anchored.headYaw(), anchored.bodyYaw()); + } + } + + for (UUID uuid : actorUuids) { + ServerAnimationController.OriginalTransform ot = originals.get(uuid); + if (ot != null) { + return new ServerAnimationController.SharedTransform(ot.pos(), ot.yaw(), ot.pitch(), ot.headYaw(), ot.bodyYaw()); + } + } + + return new ServerAnimationController.SharedTransform(Vec3d.ZERO, 0.0F, 0.0F, 0.0F, 0.0F); + } + + private static Entity pickAnchorEntity(ServerWorld world, List actorUuids, @Nullable UUID preferredAnchorUuid) { + Entity anchor = resolveAnchorEntity(world, actorUuids, preferredAnchorUuid); + return anchor != null ? anchor : pickAnchorEntity(world, actorUuids); + } + + private static Entity pickAnchorEntity(ServerWorld world, List actorUuids) { + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e instanceof PlayerEntity) { + return e; + } + } + + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e != null) { + return e; + } + } + + return null; + } + + private static boolean waterRequirementMet(ServerWorld world, @Nullable AnimationDefinitions.Definition def, List actorUuids) { + AnimationDefinitions.WaterRequirement req = def == null ? AnimationDefinitions.WaterRequirement.NONE : def.waterRequirement(); + return req == AnimationDefinitions.WaterRequirement.NONE ? !anyActorOnWaterFooting(world, actorUuids) : findWaterAnchor(world, actorUuids, req) != null; + } + + private static boolean anyActorOnWaterFooting(ServerWorld world, List actorUuids) { + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e != null) { + BlockPos underFeet = e.getBlockPos().down(); + if (world.getFluidState(underFeet).isIn(FluidTags.WATER)) { + return true; + } + } + } + + return false; + } + + @Nullable + private static Entity findWaterAnchor(ServerWorld world, List actorUuids, AnimationDefinitions.WaterRequirement requirement) { + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e != null && meetsWaterRequirement(e, requirement)) { + return e; + } + } + + return null; + } + + private static boolean meetsWaterRequirement(Entity e, AnimationDefinitions.WaterRequirement requirement) { + return switch (requirement) { + case SURFACE -> e.isTouchingWater() && !e.isSubmergedInWater(); + case UNDERWATER -> e.isSubmergedInWater(); + case NONE -> true; + }; + } + + @Nullable + private static ServerAnimationController.BlockPlacement findWallPlacement( + ServerWorld world, Entity anchor, AnimationDefinitions.BlockRequirements requirements + ) { + Vec3d anchorPos = anchor.getEntityPos(); + BlockPos anchorBlock = BlockPos.ofFloored(anchorPos); + int radius = getBlockScanRadius(); + double bestDistSq = Double.MAX_VALUE; + ServerAnimationController.BlockPlacement best = null; + int minY = anchorBlock.getY() - 1; + int maxY = anchorBlock.getY() + 1; + + for (int y = minY; y <= maxY; y++) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + BlockPos pos = new BlockPos(anchorBlock.getX() + dx, y, anchorBlock.getZ() + dz); + + for (Direction facing : HORIZONTAL_DIRECTIONS) { + ServerAnimationController.WallSegment segment = resolveWallSegment(world, pos, facing, requirements); + if (segment != null && checkWallClearance(world, segment, facing, requirements.clearance())) { + Vec3d sharedPos = segment.surfaceCenter(); + double distSq = sharedPos.squaredDistanceTo(anchorPos); + if (!(distSq >= bestDistSq) && hasBlockRequirementLineOfSight(world, anchor, sharedPos)) { + float yaw = directionToYaw(facing.getOpposite()); + bestDistSq = distSq; + ServerAnimationController.SharedTransform transform = new ServerAnimationController.SharedTransform(sharedPos, yaw, 0.0F, yaw, yaw); + best = new ServerAnimationController.BlockPlacement(segment.columns().getFirst().pos(), facing, transform, null, false); + } + } + } + } + } + } + + return best; + } + + @Nullable + private static ServerAnimationController.BlockPlacement findBlockPlacement( + ServerWorld world, Entity anchor, AnimationDefinitions.BlockRequirements requirements + ) { + if (requirements == null) { + return null; + } else if (requirements.isWall()) { + return findWallPlacement(world, anchor, requirements); + } else { + return requirements.isCenterSupport() ? findCenterSupportPlacement(world, anchor, requirements) : null; + } + } + + @Nullable + private static ServerAnimationController.BlockPlacement findCenterSupportPlacement( + ServerWorld world, Entity anchor, AnimationDefinitions.BlockRequirements requirements + ) { + Vec3d anchorPos = anchor.getEntityPos(); + BlockPos anchorBlock = BlockPos.ofFloored(anchorPos); + int radius = getBlockScanRadius(); + double bestDistSq = Double.MAX_VALUE; + ServerAnimationController.BlockPlacement best = null; + int minY = anchorBlock.getY() - 1; + int maxY = anchorBlock.getY() + 1; + + for (int y = minY; y <= maxY; y++) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + BlockPos supportPos = new BlockPos(anchorBlock.getX() + dx, y, anchorBlock.getZ() + dz); + Double supportTopOffset = supportTopOffset(world, supportPos, requirements.support(), requirements.blocks()); + if (supportTopOffset != null) { + Direction[] directions = directionsByDistanceToAnchor(anchorPos, supportPos, supportTopOffset); + if (requirements.placement() != AnimationDefinitions.CenterPlacement.SURFACE_FOOTPRINT) { + if (checkCenterSupportSurfaceRadius(world, supportPos, supportTopOffset, requirements.surfaceRadius(), requirements.clearance())) { + for (Direction direction : directions) { + if (checkCenterDirectionalClearance(world, supportPos, direction, supportTopOffset, requirements.clearance())) { + BlockPos sidePos = supportPos.offset(direction); + Vec3d sideCenter = new Vec3d(sidePos.getX() + 0.5, supportPos.getY() + supportTopOffset, sidePos.getZ() + 0.5); + double distSq = sideCenter.squaredDistanceTo(anchorPos); + if (distSq < bestDistSq) { + float yaw = directionToYaw(direction.getOpposite()); + Vec3d sharedPos = new Vec3d(supportPos.getX() + 0.5, supportPos.getY(), supportPos.getZ() + 0.5); + if (hasBlockRequirementLineOfSight(world, anchor, sideCenter)) { + bestDistSq = distSq; + ServerAnimationController.SharedTransform transform = new ServerAnimationController.SharedTransform( + sharedPos, yaw, 0.0F, yaw, yaw + ); + best = new ServerAnimationController.BlockPlacement(supportPos, direction, transform, null, true); + } + } + } + } + } + } else { + for (Direction direction : directions) { + for (int lateralSign : new int[]{1, -1}) { + if (checkCenterSurfaceFootprint(world, supportPos, direction, lateralSign, supportTopOffset, requirements)) { + ServerAnimationController.BedFootprintFacing bedFacing = resolveBedFootprintFacing( + world, supportPos, direction, lateralSign, requirements + ); + if (!bedFacing.bedFootprint() || bedFacing.facing() != null) { + Vec3d footprintCenter = centerSurfaceFootprint( + supportPos, direction, lateralSign, supportTopOffset, requirements.surfaceFootprint() + ); + double distSq = footprintCenter.squaredDistanceTo(anchorPos); + if (distSq < bestDistSq) { + Direction placementFacing = bedFacing.facing() == null ? direction : bedFacing.facing().getOpposite(); + BlockPos placementPos = supportPos; + if (bedFacing.facing() != null) { + AnimationDefinitions.SurfaceFootprint footprint = requirements.surfaceFootprint(); + Direction side = lateralSign >= 0 ? direction.rotateYCounterclockwise() : direction.rotateYClockwise(); + placementPos = supportPos.offset(bedFacing.facing(), Math.max(0, footprint.depth() - 1)) + .offset(side, Math.max(0, footprint.width() - 1)); + } + + float yaw = directionToYaw(placementFacing.getOpposite()); + Vec3d sharedPos = new Vec3d(placementPos.getX() + 0.5, placementPos.getY(), placementPos.getZ() + 0.5); + if (hasBlockRequirementLineOfSight(world, anchor, footprintCenter)) { + bestDistSq = distSq; + ServerAnimationController.SharedTransform transform = new ServerAnimationController.SharedTransform( + sharedPos, yaw, 0.0F, yaw, yaw + ); + best = new ServerAnimationController.BlockPlacement(placementPos, placementFacing, transform, null, true); + } + } + } + } + } + } + } + } + } + } + } + + return best; + } + + private static Direction[] directionsByDistanceToAnchor(Vec3d anchorPos, BlockPos supportPos, double supportTopOffset) { + Direction[] directions = new Direction[]{Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + Arrays.sort(directions, Comparator.comparingDouble(direction -> { + BlockPos side = supportPos.offset(direction); + Vec3d center = new Vec3d(side.getX() + 0.5, supportPos.getY() + supportTopOffset, side.getZ() + 0.5); + return center.squaredDistanceTo(anchorPos); + })); + return directions; + } + + @Nullable + private static Double supportTopOffset( + ServerWorld world, BlockPos pos, AnimationDefinitions.CenterSupport support, @Nullable AnimationDefinitions.BlockPredicate blocks + ) { + if (support == null) { + return null; + } + + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return null; + } + + if (!matchesBlockPredicate(state, blocks)) { + return null; + } + + if (support == AnimationDefinitions.CenterSupport.SURFACE) { + if (blocks != null && !blocks.isEmpty()) { + VoxelShape shape = state.getCollisionShape(world, pos); + return shape.isEmpty() ? null : shape.getMax(Axis.Y); + } else { + return null; + } + } else { + if (support == AnimationDefinitions.CenterSupport.HALF_HEIGHT) { + return isHalfHeightSupport(state) ? 0.5625 : null; + } + + if (state.getBlock() instanceof SlabBlock) { + SlabType type = (SlabType)state.get(SlabBlock.TYPE); + if (support == AnimationDefinitions.CenterSupport.SLAB) { + return type == SlabType.BOTTOM ? 0.5 : null; + } + + if (support == AnimationDefinitions.CenterSupport.FULL_BLOCK) { + return type != SlabType.TOP && type != SlabType.DOUBLE ? null : 1.0; + } + } + + return support == AnimationDefinitions.CenterSupport.FULL_BLOCK && isFullHeightSupport(world, pos, state) ? 1.0 : null; + } + } + + private static List> resolveModelRootsByActor( + ServerWorld world, List actorUuids, @Nullable AnimationDefinitions.AnimationPackInfo packInfo + ) { + if (actorUuids != null && !actorUuids.isEmpty()) { + if (packInfo == null) { + return Collections.nCopies(actorUuids.size(), List.of()); + } + + List> rootsByActor = new ArrayList<>(actorUuids.size()); + + for (UUID actorUuid : actorUuids) { + Entity actor = resolveAnchorEntity(world, actorUuids, actorUuid); + Identifier entityTypeId = actor == null ? null : Registries.ENTITY_TYPE.getId(actor.getType()); + rootsByActor.add(packInfo.modelRootsFor(entityTypeId)); + } + + return List.copyOf(rootsByActor); + } else { + return List.of(); + } + } + + private static boolean isHalfHeightSupport(BlockState state) { + return state.getBlock() instanceof BedBlock ? true : state.getBlock() instanceof SlabBlock && state.get(SlabBlock.TYPE) == SlabType.BOTTOM; + } + + private static boolean isFullHeightSupport(ServerWorld world, BlockPos pos, BlockState state) { + if (state.isAir() || state.isReplaceable()) { + return false; + } + + if (state.isSolidBlock(world, pos)) { + return true; + } + + VoxelShape shape = state.getCollisionShape(world, pos); + return !shape.isEmpty() && shape.getMax(Axis.Y) >= 0.999; + } + + private static boolean checkCenterDirectionalClearance( + ServerWorld world, BlockPos supportPos, Direction facing, double supportTopOffset, AnimationDefinitions.Clearance clearance + ) { + if (clearance == null) { + return true; + } + + int lateralRadius = Math.max(1, clearance.width()) - 1; + int height = Math.max(1, clearance.height()); + int depth = Math.max(1, clearance.depth()); + Direction left = facing.rotateYCounterclockwise(); + + for (int d = 1; d <= depth; d++) { + for (int w = -lateralRadius; w <= lateralRadius; w++) { + BlockPos check = supportPos.offset(facing, d).offset(left, w); + if (!isDirectionalClearanceFree(world, check, supportPos.getY(), height)) { + return false; + } + + if (!isSolidFloorBlock(world, check.down())) { + return false; + } + } + } + + return true; + } + + private static boolean checkCenterSurfaceFootprint( + ServerWorld world, BlockPos supportPos, Direction facing, int lateralSign, double supportTopOffset, AnimationDefinitions.BlockRequirements requirements + ) { + AnimationDefinitions.SurfaceFootprint footprint = requirements.surfaceFootprint(); + if (footprint == null) { + return false; + } + + int width = Math.max(1, footprint.width()); + int depth = Math.max(1, footprint.depth()); + int height = Math.max(1, footprint.height()); + int margin = Math.max(0, footprint.margin()); + Direction side = lateralSign >= 0 ? facing.rotateYCounterclockwise() : facing.rotateYClockwise(); + double supportTopY = supportPos.getY() + supportTopOffset; + + for (int d = -margin; d < depth + margin; d++) { + for (int w = -margin; w < width + margin; w++) { + BlockPos check = supportPos.offset(facing, d).offset(side, w); + boolean insideFootprint = d >= 0 && d < depth && w >= 0 && w < width; + if (insideFootprint) { + Double checkTopOffset = supportTopOffset(world, check, requirements.support(), requirements.blocks()); + if (checkTopOffset == null || Math.abs(checkTopOffset - supportTopOffset) > 1.0E-4) { + return false; + } + + if (!isSpaceClearAboveSupport(world, check, supportTopY, height)) { + return false; + } + } else if (!isSurfaceFootprintMarginClear(world, check, supportTopY, height)) { + return false; + } + } + } + + return true; + } + + private static ServerAnimationController.BedFootprintFacing resolveBedFootprintFacing( + ServerWorld world, BlockPos supportPos, Direction facing, int lateralSign, AnimationDefinitions.BlockRequirements requirements + ) { + AnimationDefinitions.SurfaceFootprint footprint = requirements.surfaceFootprint(); + if (footprint == null) { + return ServerAnimationController.BedFootprintFacing.none(); + } + + List positions = centerSurfaceFootprintPositions(supportPos, facing, lateralSign, footprint); + if (positions.isEmpty()) { + return ServerAnimationController.BedFootprintFacing.none(); + } + + int bedCount = 0; + + for (BlockPos pos : positions) { + if (world.getBlockState(pos).getBlock() instanceof BedBlock) { + bedCount++; + } + } + + if (bedCount == 0) { + return ServerAnimationController.BedFootprintFacing.none(); + } + + boolean bedOnlyPredicate = isBedOnlyBlockPredicate(requirements.blocks()); + if (bedCount != positions.size()) { + return bedOnlyPredicate ? ServerAnimationController.BedFootprintFacing.invalid() : ServerAnimationController.BedFootprintFacing.none(); + } + + Set footprintPositions = new HashSet<>(positions); + Set pairedPositions = new HashSet<>(); + Direction expectedFacing = null; + + for (BlockPos pos : positions) { + BlockState state = world.getBlockState(pos); + Direction bedFacing = (Direction)state.get(BedBlock.FACING); + BedPart part = (BedPart)state.get(BedBlock.PART); + if (expectedFacing == null) { + expectedFacing = bedFacing; + } else if (expectedFacing != bedFacing) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } + + BlockPos otherPos = part == BedPart.FOOT ? pos.offset(bedFacing) : pos.offset(bedFacing.getOpposite()); + if (!footprintPositions.contains(otherPos)) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } + + BlockState otherState = world.getBlockState(otherPos); + if (!(otherState.getBlock() instanceof BedBlock)) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } + + if (otherState.get(BedBlock.FACING) != bedFacing) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } + + if (otherState.get(BedBlock.PART) == part) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } + + pairedPositions.add(pos); + pairedPositions.add(otherPos); + } + + if (!pairedPositions.containsAll(footprintPositions) || expectedFacing == null) { + return ServerAnimationController.BedFootprintFacing.invalid(); + } else { + return facing == expectedFacing && lateralSign >= 0 + ? ServerAnimationController.BedFootprintFacing.valid(expectedFacing) + : ServerAnimationController.BedFootprintFacing.invalid(); + } + } + + private static List centerSurfaceFootprintPositions( + BlockPos supportPos, Direction facing, int lateralSign, AnimationDefinitions.SurfaceFootprint footprint + ) { + int width = Math.max(1, footprint == null ? 1 : footprint.width()); + int depth = Math.max(1, footprint == null ? 1 : footprint.depth()); + Direction side = lateralSign >= 0 ? facing.rotateYCounterclockwise() : facing.rotateYClockwise(); + List positions = new ArrayList<>(width * depth); + + for (int d = 0; d < depth; d++) { + for (int w = 0; w < width; w++) { + positions.add(supportPos.offset(facing, d).offset(side, w)); + } + } + + return positions; + } + + private static boolean isBedOnlyBlockPredicate(@Nullable AnimationDefinitions.BlockPredicate blocks) { + if (blocks != null && !blocks.isEmpty()) { + for (Identifier tagId : blocks.tagIds()) { + if (!BEDS_BLOCK_TAG_ID.equals(tagId)) { + return false; + } + } + + for (Identifier blockId : blocks.blockIds()) { + if (!(Registries.BLOCK.get(blockId) instanceof BedBlock)) { + return false; + } + } + + return !blocks.tagIds().isEmpty() || !blocks.blockIds().isEmpty(); + } else { + return false; + } + } + + private static boolean isSurfaceFootprintMarginClear(ServerWorld world, BlockPos pos, double supportTopY, int height) { + Box clearanceBox = new Box(pos.getX(), supportTopY + 1.0E-4, pos.getZ(), pos.getX() + 1.0, supportTopY + Math.max(1, height), pos.getZ() + 1.0); + return world.isSpaceEmpty(clearanceBox) && !containsFluid(world, clearanceBox); + } + + private static Vec3d centerSurfaceFootprint( + BlockPos supportPos, Direction facing, int lateralSign, double supportTopOffset, AnimationDefinitions.SurfaceFootprint footprint + ) { + int width = Math.max(1, footprint == null ? 1 : footprint.width()); + int depth = Math.max(1, footprint == null ? 1 : footprint.depth()); + Direction side = lateralSign >= 0 ? facing.rotateYCounterclockwise() : facing.rotateYClockwise(); + double forwardOffset = (depth - 1) * 0.5; + double sideOffset = (width - 1) * 0.5; + return new Vec3d( + supportPos.getX() + 0.5 + facing.getOffsetX() * forwardOffset + side.getOffsetX() * sideOffset, + supportPos.getY() + supportTopOffset, + supportPos.getZ() + 0.5 + facing.getOffsetZ() * forwardOffset + side.getOffsetZ() * sideOffset + ); + } + + private static boolean checkCenterSupportSurfaceRadius( + ServerWorld world, BlockPos supportPos, double supportTopOffset, int surfaceRadius, AnimationDefinitions.Clearance clearance + ) { + int radius = Math.max(0, surfaceRadius); + int height = Math.max(1, clearance == null ? 2 : clearance.height()); + double supportTopY = supportPos.getY() + supportTopOffset; + + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + BlockPos check = supportPos.add(dx, 0, dz); + if (!isSameOrLowerSurface(world, check, supportTopOffset)) { + return false; + } + + if (!isSpaceClearAboveSupport(world, check, supportTopY, height)) { + return false; + } + } + } + + return true; + } + + private static boolean isSameOrLowerSurface(ServerWorld world, BlockPos pos, double supportTopOffset) { + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return false; + } else if (!state.isAir() && !state.isReplaceable()) { + VoxelShape shape = state.getCollisionShape(world, pos); + return shape.isEmpty() ? true : shape.getMax(Axis.Y) <= supportTopOffset + 1.0E-4; + } else { + return true; + } + } + + private static boolean isDirectionalClearanceFree(ServerWorld world, BlockPos pos, int supportY, int height) { + Box clearanceBox = new Box(pos.getX(), supportY + 1.0E-4, pos.getZ(), pos.getX() + 1.0, supportY + Math.max(1, height), pos.getZ() + 1.0); + return world.isSpaceEmpty(clearanceBox) && !containsFluid(world, clearanceBox); + } + + private static boolean isSpaceClearAboveSupport(ServerWorld world, BlockPos pos, double supportTopY, int height) { + Box clearanceBox = new Box(pos.getX(), supportTopY + 1.0E-4, pos.getZ(), pos.getX() + 1.0, supportTopY + Math.max(1, height), pos.getZ() + 1.0); + return world.isSpaceEmpty(clearanceBox) && !containsFluid(world, clearanceBox); + } + + private static Text blockRequirementFailureMessage( + AnimationDefinitions.BlockRequirements requirements, Identifier animationId, ServerWorld world, List actorUuids + ) { + String animation = describeAnimationIdForDebug(animationId); + String actors = describeActorsForDebug(world, actorUuids); + return requirements != null && requirements.isCenterSupport() + ? dbg("start_cancelled_no_valid_center_support", animation, actors) + : dbg("start_cancelled_no_valid_wall", animation, actors); + } + + private static boolean checkWallClearance( + ServerWorld world, ServerAnimationController.WallSegment segment, Direction facing, AnimationDefinitions.Clearance clearance + ) { + if (clearance == null) { + return true; + } + + if (segment != null && !segment.columns().isEmpty()) { + int sidePadding = Math.max(1, clearance.width()) - 1; + int height = Math.max(1, clearance.height()); + int depth = Math.max(1, clearance.depth()); + Direction left = facing.rotateYCounterclockwise(); + BlockPos firstWallPos = segment.columns().getFirst().pos(); + int wallWidth = segment.columns().size(); + + for (int dy = 0; dy < height; dy++) { + for (int d = 1; d <= depth; d++) { + for (int w = -sidePadding; w < wallWidth + sidePadding; w++) { + BlockPos check = firstWallPos.offset(facing, d).offset(left, w).up(dy); + if (!isFreeSpace(world, check)) { + return false; + } + + if (dy == 0 && !isSolidFloorBlock(world, check.down())) { + return false; + } + } + } + } + + return true; + } else { + return false; + } + } + + @Nullable + private static ServerAnimationController.WallSegment resolveWallSegment( + ServerWorld world, BlockPos firstPos, Direction facing, AnimationDefinitions.BlockRequirements requirements + ) { + if (world != null && firstPos != null && facing != null && requirements != null) { + AnimationDefinitions.WallWidth width = requirements.width(); + int minimum = Math.max(1, width == null ? 1 : width.min()); + Direction lateral = facing.rotateYCounterclockwise(); + List columns = new ArrayList<>(minimum); + + for (int index = 0; index < minimum; index++) { + ServerAnimationController.WallColumn column = resolveWallColumn(world, firstPos.offset(lateral, index), facing, requirements); + if (column == null) { + return null; + } + + columns.add(column); + } + + Integer maximum = width == null ? null : width.max(); + if (maximum != null && exceedsMaximumWallRun(world, firstPos, facing, requirements, maximum)) { + return null; + } + + double x = 0.0; + double y = 0.0; + double z = 0.0; + + for (ServerAnimationController.WallColumn column : columns) { + x += column.surfaceAnchor().x; + y += column.surfaceAnchor().y; + z += column.surfaceAnchor().z; + } + + double divisor = columns.size(); + return new ServerAnimationController.WallSegment(List.copyOf(columns), new Vec3d(x / divisor, y / divisor, z / divisor)); + } else { + return null; + } + } + + @Nullable + private static ServerAnimationController.WallColumn resolveWallColumn( + ServerWorld world, BlockPos pos, Direction facing, AnimationDefinitions.BlockRequirements requirements + ) { + if (!isSolidFloorBlock(world, pos.down())) { + return null; + } + + if (!isWallRequirementBlock(world, pos, facing, requirements)) { + return null; + } + + int wallHeight = countWallHeight(world, pos, facing, requirements.height(), requirements); + if (matchesWallHeight(wallHeight, requirements.height()) && hasWallHeightCapClearance(world, pos, wallHeight, requirements.height())) { + VoxelShape wallShape = world.getBlockState(pos).getCollisionShape(world, pos); + if (wallShape.isEmpty()) { + return null; + } + + EnumMap broadSurfaces = findBroadWallSurfaces(wallShape.getBoundingBoxes()); + Double broadSurface = broadSurfaces.get(facing); + return !broadSurfaces.isEmpty() && broadSurface == null + ? null + : new ServerAnimationController.WallColumn(pos, wallSurfaceAnchor(pos, wallShape, facing, broadSurface)); + } else { + return null; + } + } + + private static boolean exceedsMaximumWallRun( + ServerWorld world, BlockPos origin, Direction facing, AnimationDefinitions.BlockRequirements requirements, int maximum + ) { + int count = 1; + Direction lateral = facing.rotateYCounterclockwise(); + + for (Direction scanDirection : new Direction[]{lateral, lateral.getOpposite()}) { + for (BlockPos cursor = origin.offset(scanDirection); + count <= maximum && resolveWallColumn(world, cursor, facing, requirements) != null; + cursor = cursor.offset(scanDirection) + ) { + if (++count > maximum) { + return true; + } + } + } + + return false; + } + + private static int getBlockScanRadius() { + int radius = NeedsOfNature.getConfig().blockSearchRadius(); + return Math.max(1, radius); + } + + private static boolean hasBlockRequirementLineOfSight(ServerWorld world, Entity anchor, Vec3d target) { + if (world != null && anchor != null && target != null) { + Vec3d start = anchor.getEyePos(); + Vec3d end = target.add(0.0, 0.75, 0.0); + if (start.squaredDistanceTo(end) < 1.0E-6) { + return true; + } + + BlockHitResult result = world.raycast(new RaycastContext(start, end, ShapeType.COLLIDER, FluidHandling.NONE, anchor)); + return result == null || result.getType() == Type.MISS; + } else { + return false; + } + } + + private static void markInWallGrace(ServerWorld world, List actorUuids) { + if (actorUuids != null && !actorUuids.isEmpty()) { + long until = world.getTime() + 5L; + Map grace = IN_WALL_GRACE_BY_WORLD.computeIfAbsent(world.getRegistryKey(), k -> new HashMap<>()); + + for (UUID uuid : actorUuids) { + Long existing = grace.get(uuid); + if (existing == null || existing < until) { + grace.put(uuid, until); + } + } + } + } + + private static void cleanupInWallGrace(ServerWorld world, long now) { + Map grace = IN_WALL_GRACE_BY_WORLD.get(world.getRegistryKey()); + if (grace != null && !grace.isEmpty()) { + grace.entrySet().removeIf(entry -> entry.getValue() < now); + } + } + + private static EnumMap findBroadWallSurfaces(List boxes) { + EnumMap surfaces = new EnumMap<>(Direction.class); + + for (Direction direction : HORIZONTAL_DIRECTIONS) { + Double surface = findBroadWallSurface(boxes, direction); + if (surface != null) { + surfaces.put(direction, surface); + } + } + + return surfaces; + } + + @Nullable + private static Double findBroadWallSurface(List boxes, Direction facing) { + Axis lateralAxis = facing.getAxis() == Axis.X ? Axis.Z : Axis.X; + boolean positive = facing == Direction.EAST || facing == Direction.SOUTH; + Double bestSurface = null; + + for (Box candidate : boxes) { + double surface = boxBoundary(candidate, facing); + if (!(coveredSpanAtSurface(boxes, facing, lateralAxis, surface) + 1.0E-6 < 0.5) + && (bestSurface == null || (positive ? surface > bestSurface : surface < bestSurface))) { + bestSurface = surface; + } + } + + return bestSurface; + } + + private static double coveredSpanAtSurface(List boxes, Direction facing, Axis lateralAxis, double surface) { + List surfaceBoxes = new ArrayList<>(); + + for (Box box : boxes) { + if (Math.abs(boxBoundary(box, facing) - surface) <= 1.0E-6) { + surfaceBoxes.add(box); + } + } + + surfaceBoxes.sort(Comparator.comparingDouble(boxx -> boxMin(boxx, lateralAxis))); + double covered = 0.0; + double intervalEnd = Double.NEGATIVE_INFINITY; + + for (Box box : surfaceBoxes) { + double min = boxMin(box, lateralAxis); + double max = boxMax(box, lateralAxis); + if (min > intervalEnd) { + covered += max - min; + intervalEnd = max; + } else if (max > intervalEnd) { + covered += max - intervalEnd; + intervalEnd = max; + } + } + + return covered; + } + + private static Vec3d wallSurfaceAnchor(BlockPos pos, VoxelShape shape, Direction facing, @Nullable Double broadSurface) { + double centerX = pos.getX() + 0.5; + double centerZ = pos.getZ() + 0.5; + double surface = broadSurface != null ? broadSurface : wallShapeBoundary(shape, facing); + + return switch (facing) { + case EAST -> new Vec3d(pos.getX() + surface + 0.5, pos.getY(), centerZ); + case WEST -> new Vec3d(pos.getX() + surface - 0.5, pos.getY(), centerZ); + case SOUTH -> new Vec3d(centerX, pos.getY(), pos.getZ() + surface + 0.5); + case NORTH -> new Vec3d(centerX, pos.getY(), pos.getZ() + surface - 0.5); + default -> throw new IllegalArgumentException("Wall facing must be horizontal: " + facing); + }; + } + + private static double wallShapeBoundary(VoxelShape shape, Direction facing) { + return switch (facing) { + case EAST -> shape.getMax(Axis.X); + case WEST -> shape.getMin(Axis.X); + case SOUTH -> shape.getMax(Axis.Z); + case NORTH -> shape.getMin(Axis.Z); + default -> throw new IllegalArgumentException("Wall facing must be horizontal: " + facing); + }; + } + + private static double boxBoundary(Box box, Direction facing) { + return switch (facing) { + case EAST -> box.maxX; + case WEST -> box.minX; + case SOUTH -> box.maxZ; + case NORTH -> box.minZ; + default -> throw new IllegalArgumentException("Wall facing must be horizontal: " + facing); + }; + } + + private static double boxMin(Box box, Axis axis) { + return switch (axis) { + case X -> box.minX; + case Y -> box.minY; + case Z -> box.minZ; + default -> throw new MatchException(null, null); + }; + } + + private static double boxMax(Box box, Axis axis) { + return switch (axis) { + case X -> box.maxX; + case Y -> box.maxY; + case Z -> box.maxZ; + default -> throw new MatchException(null, null); + }; + } + + private static float directionToYaw(Direction dir) { + return switch (dir) { + case EAST -> -90.0F; + case WEST -> 90.0F; + default -> 0.0F; + case NORTH -> 180.0F; + }; + } + + private static int countWallHeight( + ServerWorld world, BlockPos pos, Direction facing, AnimationDefinitions.WallHeight height, AnimationDefinitions.BlockRequirements requirements + ) { + if (height == null) { + return 0; + } + + int min = Math.max(1, height.min()); + Integer max = height.max(); + int limit = max != null ? max + 1 : min; + int actual = 0; + + for (BlockPos cursor = pos; actual < limit && isWallRequirementBlock(world, cursor, facing, requirements); cursor = cursor.up()) { + actual++; + } + + return actual; + } + + private static boolean matchesWallHeight(int actual, AnimationDefinitions.WallHeight height) { + if (height == null) { + return false; + } + + int min = Math.max(1, height.min()); + Integer max = height.max(); + return actual < min ? false : max == null || actual <= max; + } + + private static boolean hasWallHeightCapClearance(ServerWorld world, BlockPos pos, int actual, AnimationDefinitions.WallHeight height) { + if (height == null || height.max() == null) { + return true; + } else { + return actual != height.max() ? true : isFreeSpace(world, pos.up(actual)); + } + } + + private static boolean isSolidWallBlock(ServerWorld world, BlockPos pos) { + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return false; + } + + if (state.isAir() || state.isReplaceable()) { + return false; + } + + if (!state.isIn(BlockTags.FENCES) && !state.isIn(BlockTags.WALLS) && !state.isIn(WALL_SURFACES_BLOCK_TAG)) { + VoxelShape shape = state.getCollisionShape(world, pos); + if (shape.isEmpty()) { + return false; + } else if (!(shape.getMin(Axis.X) > 1.0E-6) + && !(shape.getMax(Axis.X) < 0.999999) + && !(shape.getMin(Axis.Z) > 1.0E-6) + && !(shape.getMax(Axis.Z) < 0.999999)) { + VoxelShape requiredCore = VoxelShapes.cuboid(0.0625, 0.0, 0.0625, 0.9375, 0.875, 0.9375); + return !VoxelShapes.matchesAnywhere(requiredCore, shape, BooleanBiFunction.ONLY_FIRST); + } else { + return false; + } + } else { + return true; + } + } + + private static boolean isWallRequirementBlock( + ServerWorld world, BlockPos pos, Direction wallSurfaceFacing, AnimationDefinitions.BlockRequirements requirements + ) { + AnimationDefinitions.BlockPredicate blocks = requirements == null ? null : requirements.blocks(); + if (blocks != null && !blocks.isEmpty()) { + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return false; + } else if (!matchesBlockPredicate(state, blocks)) { + return false; + } else { + return !matchesWallFacing(state, wallSurfaceFacing, requirements.facing()) ? false : !state.getCollisionShape(world, pos).isEmpty(); + } + } else { + return isSolidWallBlock(world, pos); + } + } + + private static boolean matchesWallFacing(BlockState state, Direction wallSurfaceFacing, @Nullable AnimationDefinitions.WallFacing requiredFacing) { + if (requiredFacing == null) { + return true; + } + + Direction blockFacing = null; + + for (Entry, Comparable> entry : state.getEntries().entrySet()) { + if ("facing".equals(entry.getKey().getName()) && entry.getValue() instanceof Direction direction) { + blockFacing = direction; + break; + } + } + + return requiredFacing.matches(blockFacing, wallSurfaceFacing); + } + + private static boolean matchesBlockPredicate(BlockState state, @Nullable AnimationDefinitions.BlockPredicate blocks) { + if (blocks != null && !blocks.isEmpty()) { + Identifier blockId = Registries.BLOCK.getId(state.getBlock()); + if (blocks.blockIds().contains(blockId)) { + return true; + } + + for (Identifier tagId : blocks.tagIds()) { + if (state.isIn(TagKey.of(RegistryKeys.BLOCK, tagId))) { + return true; + } + } + + return false; + } else { + return true; + } + } + + private static boolean isSolidFloorBlock(ServerWorld world, BlockPos pos) { + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return false; + } + + if (state.isAir() || state.isReplaceable()) { + return false; + } + + if (state.isIn(BlockTags.FENCES) || state.isIn(BlockTags.WALLS)) { + return false; + } + + if (state.isSolidBlock(world, pos)) { + return true; + } + + VoxelShape shape = state.getCollisionShape(world, pos); + if (shape.isEmpty()) { + return false; + } + + double top = shape.getMax(Axis.Y); + if (top < 0.875) { + return false; + } + + VoxelShape requiredSupport = VoxelShapes.cuboid(0.0625, top - 0.0625, 0.0625, 0.9375, top, 0.9375); + return !VoxelShapes.matchesAnywhere(requiredSupport, shape, BooleanBiFunction.ONLY_FIRST); + } + + private static boolean isFreeSpace(ServerWorld world, BlockPos pos) { + BlockState state = world.getBlockState(pos); + return !state.getFluidState().isEmpty() ? false : state.isAir() || state.isReplaceable(); + } + + private static void applySharedTransform( + ServerWorld world, List actorUuids, ServerAnimationController.SharedTransform sharedTransform, boolean preservePlayerLook + ) { + Map states = AI_DISABLE_STATE_BY_WORLD.get(world.getRegistryKey()); + + for (UUID uuid : actorUuids) { + Entity e = world.getEntity(uuid); + if (e != null) { + switch (e) { + case ServerPlayerEntity player: + preparePlayerForAnimationTeleport(player); + float teleportYaw = preservePlayerLook ? player.getYaw() : sharedTransform.yaw(); + float teleportPitch = preservePlayerLook ? player.getPitch() : sharedTransform.pitch(); + player.networkHandler.requestTeleport(sharedTransform.pos().x, sharedTransform.pos().y, sharedTransform.pos().z, teleportYaw, teleportPitch); + player.setHeadYaw(sharedTransform.headYaw()); + player.setBodyYaw(sharedTransform.bodyYaw()); + break; + case LivingEntity living: + living.refreshPositionAndAngles( + sharedTransform.pos().x, sharedTransform.pos().y, sharedTransform.pos().z, sharedTransform.yaw(), sharedTransform.pitch() + ); + living.setHeadYaw(sharedTransform.headYaw()); + living.setBodyYaw(sharedTransform.bodyYaw()); + break; + default: + e.refreshPositionAndAngles( + sharedTransform.pos().x, sharedTransform.pos().y, sharedTransform.pos().z, sharedTransform.yaw(), sharedTransform.pitch() + ); + } + + e.setVelocity(Vec3d.ZERO); + e.fallDistance = 0.0; + if (e instanceof MobEntity mob && states != null) { + ServerAnimationController.AiDisableState state = states.get(uuid); + if (state != null) { + state.lockedYaw = sharedTransform.yaw(); + state.lockedHeadYaw = sharedTransform.headYaw(); + state.lockedBodyYaw = sharedTransform.bodyYaw(); + state.lockedPitch = sharedTransform.pitch(); + } + } + } + } + } + + private static void preparePlayerForAnimationTeleport(ServerPlayerEntity player) { + if (player != null) { + if (player.hasVehicle()) { + player.stopRiding(); + } + + if (player.isSleeping()) { + player.wakeUp(true, true); + } + + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0.0; + } + } + + @Nullable + private static ServerAnimationController.SharedTransform findSafeGroundPlacement( + ServerWorld world, ServerAnimationController.SharedTransform sharedTransform + ) { + Vec3d startPos = sharedTransform.pos(); + ServerAnimationController.FloorHit baseFloor = findNearestFloor(world, startPos, 2); + if (!baseFloor.found) { + return null; + } + + ServerAnimationController.SharedTransform base = withSharedPosition(sharedTransform, baseFloor.pos); + if (isGroundPlacementClear(world, baseFloor.pos)) { + return base; + } + + for (ServerAnimationController.GroundPlacementOffset offset : GROUND_PLACEMENT_OFFSETS) { + Vec3d candidateStart = startPos.add(offset.dx(), 0.0, offset.dz()); + ServerAnimationController.FloorHit candidateFloor = findNearestFloor(world, candidateStart, 2); + if (candidateFloor.found && isGroundPlacementClear(world, candidateFloor.pos)) { + return withSharedPosition(sharedTransform, candidateFloor.pos); + } + } + + return null; + } + + private static ServerAnimationController.SharedTransform withSharedPosition(ServerAnimationController.SharedTransform sharedTransform, Vec3d pos) { + return new ServerAnimationController.SharedTransform( + pos, sharedTransform.yaw(), sharedTransform.pitch(), sharedTransform.headYaw(), sharedTransform.bodyYaw() + ); + } + + @Nullable + private static ServerAnimationController.SharedTransform findMountedPlayerDismountTransform( + ServerWorld world, List actorUuids, ServerAnimationController.SharedTransform base + ) { + if (world != null && actorUuids != null && !actorUuids.isEmpty() && base != null) { + for (UUID actorUuid : actorUuids) { + if (world.getEntity(actorUuid) instanceof ServerPlayerEntity player && player.hasVehicle()) { + Entity vehicle = player.getVehicle(); + if (vehicle != null && !vehicle.isRemoved()) { + Vec3d dismountPos = vehicle.updatePassengerForDismount(player); + if (dismountPos == null) { + dismountPos = vehicle.getEntityPos(); + } + + return withSharedPosition(base, dismountPos); + } + } + } + + return null; + } else { + return null; + } + } + + @Nullable + private static Vec3d resolveMountedPlayerRestorePosition(ServerPlayerEntity player) { + if (player != null && player.hasVehicle()) { + Entity vehicle = player.getVehicle(); + if (vehicle != null && !vehicle.isRemoved()) { + Vec3d dismountPos = vehicle.updatePassengerForDismount(player); + if (dismountPos == null || vehicle.getBoundingBox().expand(0.05).contains(dismountPos)) { + Box box = vehicle.getBoundingBox(); + dismountPos = new Vec3d(vehicle.getX(), box.maxY + 0.05, vehicle.getZ()); + } + + return dismountPos; + } else { + return null; + } + } else { + return null; + } + } + + @Nullable + private static ServerAnimationController.SharedTransform resolveQueuedPlacementTransform( + ServerWorld world, @Nullable UUID playerUuid, ServerAnimationController.SharedTransform base, AnimationStartContext startContext + ) { + if (world == null || playerUuid == null || base == null) { + return null; + } + + if (startContext != null && startContext.queueContinuation()) { + ServerAnimationController.DeferredQueueContext context = getDeferredQueueContext(world, playerUuid); + if (context != null && context.previousPlacement() != null) { + ServerAnimationController.BlockPlacement previousPlacement = context.previousPlacement(); + if (!isCenterSupportPlacement(previousPlacement)) { + ServerAnimationController.SharedTransform previousTransform = previousPlacement.sharedTransform(); + return isGroundPlacementClear(world, previousTransform.pos()) ? withSharedPosition(base, previousTransform.pos()) : null; + } + + double supportTopOffset = supportSurfaceTopOffset(world, previousPlacement.wallPos()); + if (supportTopOffset <= 0.0) { + return null; + } + + Vec3d topPos = new Vec3d( + previousPlacement.wallPos().getX() + 0.5, previousPlacement.wallPos().getY() + supportTopOffset, previousPlacement.wallPos().getZ() + 0.5 + ); + return !isGroundPlacementClear(world, topPos) ? null : withSharedPosition(base, topPos); + } else { + return null; + } + } else { + return null; + } + } + + private static double supportSurfaceTopOffset(ServerWorld world, BlockPos pos) { + BlockState state = world.getBlockState(pos); + if (!state.getFluidState().isEmpty()) { + return -1.0; + } + + VoxelShape shape = state.getCollisionShape(world, pos); + return shape.isEmpty() ? -1.0 : shape.getMax(Axis.Y); + } + + private static boolean isGroundPlacementClear(ServerWorld world, Vec3d pos) { + Box clearanceBox = new Box(pos.x - 0.75, pos.y + 0.01, pos.z - 0.75, pos.x + 0.75, pos.y + 2.0, pos.z + 0.75); + return world.isSpaceEmpty(clearanceBox) && !containsFluid(world, clearanceBox); + } + + private static boolean containsFluid(ServerWorld world, Box box) { + int minX = MathHelper.floor(box.minX); + int maxX = MathHelper.floor(box.maxX - 1.0E-7); + int minY = MathHelper.floor(box.minY); + int maxY = MathHelper.floor(box.maxY - 1.0E-7); + int minZ = MathHelper.floor(box.minZ); + int maxZ = MathHelper.floor(box.maxZ - 1.0E-7); + Mutable mutable = new Mutable(); + + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + mutable.set(x, y, z); + if (!world.getFluidState(mutable).isEmpty()) { + return true; + } + } + } + } + + return false; + } + + private static ServerAnimationController.FloorHit findNearestFloor(ServerWorld world, Vec3d startPos, int maxDropBlocks) { + BlockPos startBlock = BlockPos.ofFloored(startPos); + Mutable cursor = new Mutable(startBlock.getX(), startBlock.getY(), startBlock.getZ()); + int minY = Math.max(world.getBottomY(), startBlock.getY() - maxDropBlocks); + double maxFloorY = startPos.y + 0.001; + + for (int y = startBlock.getY(); y >= minY; y--) { + cursor.setY(y); + BlockState state = world.getBlockState(cursor); + if (!state.isAir()) { + VoxelShape shape = state.getCollisionShape(world, cursor); + if (!shape.isEmpty()) { + double top = shape.getMax(Axis.Y); + if (!(top <= 0.0)) { + double floorY = y + top; + if (floorY <= maxFloorY) { + return new ServerAnimationController.FloorHit(true, new Vec3d(startPos.x, floorY, startPos.z)); + } + } + } + } + } + + return new ServerAnimationController.FloorHit(false, startPos); + } + + private static List createGroundPlacementOffsets() { + int steps = Math.max(1, MathHelper.ceil(4.0)); + double maxDistSq = 4.0; + List offsets = new ArrayList<>(); + + for (int ix = -steps; ix <= steps; ix++) { + for (int iz = -steps; iz <= steps; iz++) { + if (ix != 0 || iz != 0) { + double dx = ix * 0.5; + double dz = iz * 0.5; + double distSq = dx * dx + dz * dz; + if (!(distSq > maxDistSq)) { + offsets.add(new ServerAnimationController.GroundPlacementOffset(dx, dz, distSq)); + } + } + } + } + + offsets.sort(Comparator.comparingDouble(ServerAnimationController.GroundPlacementOffset::distSq)); + return List.copyOf(offsets); + } + + private static void restoreTransforms(ServerAnimationController.ActiveInstance inst) { + restoreTransforms(inst, null); + } + + private static void restoreTransforms(ServerAnimationController.ActiveInstance inst, @Nullable Set skipActors) { + Map originals = inst.originalTransforms; + if (originals != null && !originals.isEmpty()) { + Map states = AI_DISABLE_STATE_BY_WORLD.get(inst.world.getRegistryKey()); + + for (Entry entry : originals.entrySet()) { + if (skipActors == null || !skipActors.contains(entry.getKey())) { + ServerAnimationController.OriginalTransform ot = entry.getValue(); + if (ot.worldKey() == inst.world.getRegistryKey()) { + Entity e = inst.world.getEntity(entry.getKey()); + if (e != null) { + switch (e) { + case ServerPlayerEntity player: + preparePlayerForAnimationTeleport(player); + Vec3d restorePos = ot.restorePosOrPos(); + player.networkHandler.requestTeleport(restorePos.x, restorePos.y, restorePos.z, player.getYaw(), player.getPitch()); + clearDeferredQueueOriginal(inst.world, entry.getKey()); + break; + case LivingEntity living: + living.refreshPositionAndAngles(ot.pos().x, ot.pos().y, ot.pos().z, ot.yaw(), ot.pitch()); + living.setHeadYaw(ot.headYaw()); + living.setBodyYaw(ot.bodyYaw()); + break; + default: + e.refreshPositionAndAngles(ot.pos().x, ot.pos().y, ot.pos().z, ot.yaw(), ot.pitch()); + } + + e.setVelocity(Vec3d.ZERO); + e.fallDistance = 0.0; + if (e instanceof MobEntity && states != null) { + ServerAnimationController.AiDisableState state = states.get(entry.getKey()); + if (state != null) { + state.lockedYaw = ot.yaw(); + state.lockedHeadYaw = ot.headYaw(); + state.lockedBodyYaw = ot.bodyYaw(); + state.lockedPitch = ot.pitch(); + } + } + } + } + } + } + } + } + + @Nullable + private static ServerAnimationController.OriginalTransform getOriginalTransformForActor(ServerAnimationController.ActiveInstance inst, UUID actorUuid) { + return inst != null && actorUuid != null && inst.originalTransforms != null ? inst.originalTransforms.get(actorUuid) : null; + } + + private static boolean startNextQueuedForPlayerDeferringRestore( + ServerWorld world, ServerAnimationController.ActiveInstance inst, @Nullable UUID ignoreInstanceId + ) { + if (world != null && inst != null && inst.playerUuid != null) { + boolean inserted = captureDeferredQueueContext(world, inst.playerUuid, inst); + boolean started = startNextQueuedForPlayer(world, inst.playerUuid, ignoreInstanceId, resolveTransitionYaw(inst)); + if (!started && inserted) { + clearDeferredQueueOriginal(world, inst.playerUuid); + } + + return started; + } else { + return false; + } + } + + private static boolean captureDeferredQueueContextFromActiveInstance(ServerWorld world, UUID playerUuid) { + ServerAnimationController.ActiveInstance active = findActiveInstanceForActors(world, List.of(playerUuid)); + return captureDeferredQueueContext(world, playerUuid, active); + } + + private static boolean captureDeferredQueueContext(ServerWorld world, UUID playerUuid, @Nullable ServerAnimationController.ActiveInstance inst) { + if (world != null && playerUuid != null && inst != null) { + ServerAnimationController.OriginalTransform original = getOriginalTransformForActor(inst, playerUuid); + if (original == null) { + return false; + } + + Map byPlayer = DEFERRED_QUEUE_CONTEXT_BY_WORLD.computeIfAbsent( + world.getRegistryKey(), ignored -> new HashMap<>() + ); + ServerAnimationController.DeferredQueueContext context = new ServerAnimationController.DeferredQueueContext( + original, queueContinuationPlacementForInstance(inst) + ); + return byPlayer.putIfAbsent(playerUuid, context) == null; + } else { + return false; + } + } + + private static Map applyDeferredQueueOriginal( + ServerWorld world, @Nullable UUID playerUuid, Map originals, AnimationStartContext startContext + ) { + if (world == null || playerUuid == null || originals == null || originals.isEmpty()) { + return originals; + } + + if (startContext != null && startContext.queueContinuation()) { + ServerAnimationController.OriginalTransform deferred = getDeferredQueueOriginal(world, playerUuid); + if (deferred == null) { + return originals; + } + + Map carried = new HashMap<>(originals); + carried.put(playerUuid, deferred); + return carried; + } else { + return originals; + } + } + + @Nullable + private static ServerAnimationController.OriginalTransform getDeferredQueueOriginal(ServerWorld world, UUID playerUuid) { + ServerAnimationController.DeferredQueueContext context = getDeferredQueueContext(world, playerUuid); + return context == null ? null : context.originalTransform(); + } + + private static void clearDeferredQueueOriginal(ServerWorld world, UUID playerUuid) { + Map byPlayer = DEFERRED_QUEUE_CONTEXT_BY_WORLD.get(world.getRegistryKey()); + if (byPlayer != null) { + byPlayer.remove(playerUuid); + if (byPlayer.isEmpty()) { + DEFERRED_QUEUE_CONTEXT_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + + @Nullable + private static ServerAnimationController.DeferredQueueContext getDeferredQueueContext(ServerWorld world, UUID playerUuid) { + Map byPlayer = DEFERRED_QUEUE_CONTEXT_BY_WORLD.get(world.getRegistryKey()); + return byPlayer == null ? null : byPlayer.get(playerUuid); + } + + @Nullable + private static ServerAnimationController.BlockPlacement queueContinuationPlacementForInstance(ServerAnimationController.ActiveInstance inst) { + return inst != null && inst.blockPlacement != null ? inst.blockPlacement : null; + } + + private static boolean isCenterSupportPlacement(ServerAnimationController.BlockPlacement placement) { + return placement != null && placement.centerSupport(); + } + + private static void disableAiForActors(ServerWorld world, List actorUuids) { + if (!actorUuids.isEmpty()) { + Map states = AI_DISABLE_STATE_BY_WORLD.computeIfAbsent(world.getRegistryKey(), k -> new HashMap<>()); + + for (UUID uuid : actorUuids) { + if (world.getEntity(uuid) instanceof MobEntity mob) { + ServerAnimationController.AiDisableState state = states.get(uuid); + if (state == null) { + boolean originalAiDisabled = mob.isAiDisabled(); + state = new ServerAnimationController.AiDisableState(originalAiDisabled); + states.put(uuid, state); + } else { + state.locks++; + state.needsRestore = false; + } + + captureOrientation(mob, state); + applyNoAiTags(mob, state); + mob.setAiDisabled(true); + mob.getNavigation().stop(); + mob.setVelocity(Vec3d.ZERO); + mob.fallDistance = 0.0; + mob.setYaw(state.lockedYaw); + mob.setPitch(state.lockedPitch); + mob.setHeadYaw(state.lockedHeadYaw); + mob.setBodyYaw(state.lockedBodyYaw); + } + } + } + } + + private static void restoreAiForActors(ServerWorld world, List actorUuids) { + if (!actorUuids.isEmpty()) { + Map states = AI_DISABLE_STATE_BY_WORLD.get(world.getRegistryKey()); + if (states != null) { + for (UUID uuid : actorUuids) { + ServerAnimationController.AiDisableState state = states.get(uuid); + if (state != null) { + if (state.locks > 1) { + state.locks--; + } else { + state.locks = 0; + state.needsRestore = true; + tryRestoreAi(world, uuid, state, states); + } + } + } + + if (states.isEmpty()) { + AI_DISABLE_STATE_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + } + + private static void tryRestoreAi( + ServerWorld world, UUID actorUuid, ServerAnimationController.AiDisableState state, Map states + ) { + Entity e = world.getEntity(actorUuid); + if (e instanceof MobEntity mob) { + restoreAiState(mob, state); + states.remove(actorUuid); + } else if (e != null) { + states.remove(actorUuid); + } + } + + private static void processPendingAiRestores(ServerWorld world) { + Map states = AI_DISABLE_STATE_BY_WORLD.get(world.getRegistryKey()); + if (states != null && !states.isEmpty()) { + Iterator> it = states.entrySet().iterator(); + + while (it.hasNext()) { + Entry entry = it.next(); + ServerAnimationController.AiDisableState state = entry.getValue(); + if (state.locks <= 0 && state.needsRestore) { + Entity e = world.getEntity(entry.getKey()); + if (e instanceof MobEntity mob) { + restoreAiState(mob, state); + it.remove(); + } else if (e != null) { + it.remove(); + } + } + } + + if (states.isEmpty()) { + AI_DISABLE_STATE_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + + private static void freezeActiveActors(ServerWorld world, Set actorUuids) { + if (!actorUuids.isEmpty()) { + Map states = AI_DISABLE_STATE_BY_WORLD.get(world.getRegistryKey()); + if (states != null && !states.isEmpty()) { + for (UUID uuid : actorUuids) { + ServerAnimationController.AiDisableState state = states.get(uuid); + if (state != null && world.getEntity(uuid) instanceof MobEntity mob) { + freezeMobDuringAnimation(mob, state); + } + } + } + } + } + + private static void captureOrientation(MobEntity mob, ServerAnimationController.AiDisableState state) { + state.lockedYaw = mob.getYaw(); + state.lockedHeadYaw = mob.getHeadYaw(); + state.lockedBodyYaw = mob.getBodyYaw(); + state.lockedPitch = mob.getPitch(); + } + + private static void freezeMobDuringAnimation(MobEntity mob, ServerAnimationController.AiDisableState state) { + mob.setVelocity(Vec3d.ZERO); + mob.fallDistance = 0.0; + mob.setYaw(state.lockedYaw); + mob.setPitch(state.lockedPitch); + mob.setHeadYaw(state.lockedHeadYaw); + mob.setBodyYaw(state.lockedBodyYaw); + } + + private static void applyNoAiTags(MobEntity mob, ServerAnimationController.AiDisableState state) { + mob.addCommandTag("afw_noai"); + if (state.originalAiDisabled) { + mob.addCommandTag("afw_noai_orig"); + } else { + mob.removeCommandTag("afw_noai_orig"); + } + } + + public static void sendActiveInstancesToPlayer(ServerPlayerEntity player) { + if (player.getEntityWorld() instanceof ServerWorld world) { + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world) { + List safeKeys = sanitizeActorKeys(inst.actorKeys, inst.actorUuids.size()); + List safeStages = inst.stages == null ? List.of() : List.copyOf(inst.stages); + int stageIndex = inst.stageTracker == null ? 0 : inst.stageTracker.stageIndex; + long stageStartTick = inst.stageTracker == null ? inst.startTick : inst.stageTracker.stageStartTick; + StartAnimationS2CPayload payload = new StartAnimationS2CPayload( + inst.animationId, + inst.modelRootsByActor, + entry.getKey(), + inst.actorUuids, + safeKeys, + safeStages, + inst.startTick, + stageIndex, + stageStartTick, + resolvePublishedStageEndTick(inst), + inst.speed, + inst.lockOrientation, + inst.lockedYaw, + inst.lockedHeadYaw, + inst.lockedPitch, + inst.cameraOrbitTarget + ); + ServerPlayNetworking.send(player, payload); + inst.subscribedPlayerUuids.add(player.getUuid()); + } + } + } + } + + private static void tickPlayerQueues(ServerWorld world) { + Map queues = PLAYER_QUEUES_BY_WORLD.get(world.getRegistryKey()); + if (queues != null && !queues.isEmpty()) { + List removePlayers = new ArrayList<>(); + + for (UUID playerUuid : List.copyOf(queues.keySet())) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(playerUuid); + if (player == null || player.getEntityWorld() != world || !player.isAlive() || player.isRemoved()) { + removePlayers.add(playerUuid); + } else if (!isActorActiveIgnoringInstance(world, playerUuid, null)) { + startNextQueuedForPlayer(world, playerUuid, null, null); + ServerAnimationController.PlayerQueueState state = queues.get(playerUuid); + if (state == null || state.entries.isEmpty()) { + removePlayers.add(playerUuid); + } + } + } + + for (UUID playerUuid : removePlayers) { + clearPlayerQueueByUuid(world, playerUuid, null, null); + } + + if (queues.isEmpty()) { + PLAYER_QUEUES_BY_WORLD.remove(world.getRegistryKey(), queues); + } + } + } + + @Nullable + private static UUID inferSinglePlayerUuid(ServerWorld world, List actors) { + UUID found = null; + + for (Entity actor : actors) { + if (actor instanceof ServerPlayerEntity player && player.getEntityWorld() == world) { + if (found != null && !found.equals(player.getUuid())) { + return null; + } + + found = player.getUuid(); + } + } + + return found; + } + + @Nullable + private static UUID resolvePlayerUuid(ServerWorld world, List actorUuids) { + if (actorUuids != null && !actorUuids.isEmpty()) { + UUID found = null; + + for (UUID uuid : actorUuids) { + Entity entity = world.getEntity(uuid); + if (entity instanceof ServerPlayerEntity) { + if (found != null && !found.equals(uuid)) { + return null; + } + + found = uuid; + } + } + + return found; + } else { + return null; + } + } + + private static void insertQueueEntry(Deque entries, ServerAnimationController.QueuedEntry entry, int insertIndex) { + List list = new ArrayList<>(entries); + int pos = resolveInsertPosition(list.size(), insertIndex); + list.add(pos, entry); + entries.clear(); + entries.addAll(list); + } + + private static int resolveInsertPosition(int size, int insertIndex) { + if (insertIndex == 0) { + return size; + } + + if (insertIndex > 0) { + return Math.max(0, Math.min(size, insertIndex - 1)); + } + + int fromEnd = size + insertIndex + 1; + return Math.max(0, Math.min(size, fromEnd)); + } + + private static int describeQueuePosition(Deque entries, UUID entryId) { + int idx = 1; + + for (ServerAnimationController.QueuedEntry entry : entries) { + if (entry.entryId.equals(entryId)) { + return idx; + } + + idx++; + } + + return Math.max(1, entries.size()); + } + + private static String describeActorForDebug(@Nullable ServerWorld world, @Nullable UUID actorUuid) { + if (actorUuid != null && world != null) { + Entity entity = world.getEntity(actorUuid); + if (entity == null && world.getServer() != null) { + ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(actorUuid); + if (player != null && player.getEntityWorld() == world) { + entity = player; + } + } + + if (entity == null) { + String uuid = actorUuid.toString(); + return "missing:" + uuid.substring(0, 8); + } else { + String display = entity.getDisplayName().getString(); + Identifier typeId = Registries.ENTITY_TYPE.getId(entity.getType()); + return display != null && !display.isBlank() ? display : typeId.getPath(); + } + } else { + return "(unknown)"; + } + } + + private static String describeActorsForDebug(@Nullable ServerWorld world, @Nullable List actorUuids) { + if (actorUuids != null && !actorUuids.isEmpty()) { + List labels = new ArrayList<>(); + + for (UUID actorUuid : actorUuids) { + if (actorUuid != null) { + labels.add(describeActorForDebug(world, actorUuid)); + } + } + + if (labels.isEmpty()) { + return "(none)"; + } + + int cap = 4; + return labels.size() <= cap ? String.join(", ", labels) : String.join(", ", labels.subList(0, cap)) + " +" + (labels.size() - cap) + " more"; + } else { + return "(none)"; + } + } + + private static String describeAnimationIdForDebug(@Nullable Identifier animationId) { + return animationId == null ? "(unknown)" : animationId.toString(); + } + + private static boolean isActorActiveIgnoringInstance(ServerWorld world, UUID actorUuid, @Nullable UUID ignoreInstanceId) { + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + if (ignoreInstanceId == null || !ignoreInstanceId.equals(entry.getKey())) { + ServerAnimationController.ActiveInstance inst = entry.getValue(); + if (inst.world == world && inst.actorUuidSet.contains(actorUuid)) { + return true; + } + } + } + + return false; + } + + private static boolean startNextQueuedForPlayer(ServerWorld world, UUID playerUuid, @Nullable UUID ignoreInstanceId, @Nullable Float transitionYaw) { + Map queues = PLAYER_QUEUES_BY_WORLD.get(world.getRegistryKey()); + if (queues == null) { + return false; + } + + ServerAnimationController.PlayerQueueState queue = queues.get(playerUuid); + if (queue != null && !queue.entries.isEmpty()) { + while (!queue.entries.isEmpty()) { + ServerAnimationController.QueuedEntry entry = queue.entries.peekFirst(); + if (entry == null) { + queue.entries.pollFirst(); + } else { + if (isActorActiveIgnoringInstance(world, playerUuid, ignoreInstanceId)) { + return false; + } + + ServerAnimationController.QueueStartResult result = tryStartQueuedEntry(world, queue, entry, transitionYaw); + if (result == ServerAnimationController.QueueStartResult.RETRY) { + return false; + } + + queue.entries.pollFirst(); + clearQueuedMobMappings(world, queue.playerUuid, entry); + if (result == ServerAnimationController.QueueStartResult.STARTED) { + if (queue.entries.isEmpty()) { + queues.remove(queue.playerUuid); + } + + return true; + } + } + } + + queues.remove(queue.playerUuid); + return false; + } else { + return false; + } + } + + private static ServerAnimationController.QueueStartResult tryStartQueuedEntry( + ServerWorld world, ServerAnimationController.PlayerQueueState queue, ServerAnimationController.QueuedEntry entry, @Nullable Float transitionYaw + ) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(queue.playerUuid); + if (player != null && player.getEntityWorld() == world && player.isAlive() && !player.isRemoved()) { + List resolvedActors = new ArrayList<>(); + boolean missingNonPlayer = false; + + for (UUID actorUuid : entry.actorUuids) { + if (actorUuid.equals(queue.playerUuid)) { + resolvedActors.add(player); + } else { + Entity entity = world.getEntity(actorUuid); + if (!(entity instanceof LivingEntity living && living.isAlive() && !living.isRemoved())) { + missingNonPlayer = true; + break; + } + + resolvedActors.add(entity); + } + } + + if (missingNonPlayer) { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_missing_non_player_actor", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } else if (resolvedActors.isEmpty()) { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_no_actors_resolved", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } else if (hasAnimationStartProtection(world, player)) { + return ServerAnimationController.QueueStartResult.RETRY; + } else { + AnimationDefinitions.Definition def = AnimationDefinitions.getDefinition(entry.animationId); + if (def == null) { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_missing_definition", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } else { + List sorted = new ArrayList<>(resolvedActors); + sorted.sort(Comparator.comparingInt(Entity::getId)); + List uuids = sorted.stream().map(Entity::getUuid).toList(); + if (!waterRequirementMet(world, def, uuids)) { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_requirements_not_met", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } else { + List actorKeys = AnimationDefinitions.resolveActorKeys(def, sorted, world.getRandom()); + AnimationStartContext nextContext = sanitizeStartContext(entry.startContext()).asQueueContinuation(); + UUID instanceId = startAndBroadcast( + world, player, def.id(), uuids, actorKeys, def.stages(), entry.damageBehavior, entry.ignoreAttackers, nextContext, false, transitionYaw + ); + if (instanceId == null) { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_start_failed", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } else { + sendOrLogDebug( + world, + player, + NonDebugChatCategory.INFO, + dbg("queued_animation_started", describeAnimationIdForDebug(def.id()), describeActorsForDebug(world, uuids)), + false + ); + return ServerAnimationController.QueueStartResult.STARTED; + } + } + } + } + } else { + sendOrLogDebug( + world, + null, + NonDebugChatCategory.WARNING, + dbg("queue_skipped_player_unavailable", describeAnimationIdForDebug(entry.animationId), describeActorsForDebug(world, entry.actorUuids)), + false + ); + return ServerAnimationController.QueueStartResult.SKIP; + } + } + + private static void clearQueuedMobMappings(ServerWorld world, UUID playerUuid, ServerAnimationController.QueuedEntry entry) { + Map byMob = QUEUED_MOB_TO_PLAYER_BY_WORLD.get(world.getRegistryKey()); + if (byMob != null && !byMob.isEmpty() && entry != null) { + for (UUID actorUuid : entry.actorUuids) { + if (actorUuid != null && !actorUuid.equals(playerUuid)) { + UUID owner = byMob.get(actorUuid); + if (playerUuid.equals(owner)) { + byMob.remove(actorUuid); + } + } + } + } + } + + private static void clearPlayerQueueByUuid(ServerWorld world, UUID playerUuid, @Nullable NonDebugChatCategory category, @Nullable Text debugMessage) { + if (world != null && playerUuid != null) { + clearDeferredQueueOriginal(world, playerUuid); + Map queues = PLAYER_QUEUES_BY_WORLD.get(world.getRegistryKey()); + if (queues != null) { + ServerAnimationController.PlayerQueueState queue = queues.remove(playerUuid); + if (queue != null) { + for (ServerAnimationController.QueuedEntry entry : queue.entries) { + clearQueuedMobMappings(world, playerUuid, entry); + } + + if (queues.isEmpty()) { + PLAYER_QUEUES_BY_WORLD.remove(world.getRegistryKey()); + } + + if (category != null && debugMessage != null) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(playerUuid); + sendOrLogDebug(world, player, category, debugMessage, false); + } + } + } + } + } + + private static void handleQueueOnStop(ServerWorld world, ServerAnimationController.ActiveInstance inst, ServerAnimationController.StopReason reason) { + if (world != null && inst != null && inst.playerUuid != null) { + switch (reason) { + case NATURAL_END: + boolean started = startNextQueuedForPlayerDeferringRestore(world, inst, null); + if (started) { + sendOrLogDebug(world, null, NonDebugChatCategory.INFO, dbg("queue_continued_for_player"), false); + } + break; + case EXTERNAL_STOP: + case DAMAGE_STOP: + case ACTOR_DEATH: + case STOP_ALL: + clearPlayerQueueByUuid(world, inst.playerUuid, NonDebugChatCategory.INFO, dbg("queue_cleared")); + case JOIN_REPLACE: + } + } + } + + public static boolean isActorPendingOrActive(ServerWorld world, UUID actorUuid) { + if (isActorActiveIgnoringInstance(world, actorUuid, null)) { + return true; + } + + Map queues = PLAYER_QUEUES_BY_WORLD.get(world.getRegistryKey()); + if (queues != null && !queues.isEmpty()) { + if (queues.containsKey(actorUuid)) { + return true; + } + + for (ServerAnimationController.PlayerQueueState queue : queues.values()) { + for (ServerAnimationController.QueuedEntry entry : queue.entries) { + if (entry.actorUuids.contains(actorUuid)) { + return true; + } + } + } + + return false; + } else { + return false; + } + } + + private static void tickNoAiCleanup(ServerWorld world) { + RegistryKey key = world.getRegistryKey(); + boolean startupWindowCleanup = isWithinStartupNoAiCleanupWindow(world); + if (startupWindowCleanup || !world.getPlayers().isEmpty()) { + if (!startupWindowCleanup) { + int ticksLeft = NOAI_CLEANUP_TICKS_BY_WORLD.getOrDefault(key, 0) - 1; + if (ticksLeft > 0) { + NOAI_CLEANUP_TICKS_BY_WORLD.put(key, ticksLeft); + return; + } + + NOAI_CLEANUP_TICKS_BY_WORLD.put(key, 6000); + } + + Map states = AI_DISABLE_STATE_BY_WORLD.get(key); + + for (Entity entity : world.iterateEntities()) { + if (entity instanceof MobEntity mob && (mob.getCommandTags().contains("afw_noai") || mob.getCommandTags().contains("afw_noai_orig"))) { + UUID uuid = mob.getUuid(); + if (startupWindowCleanup || !isActorActive(world, uuid)) { + ServerAnimationController.AiDisableState state = states == null ? null : states.get(uuid); + if (state != null) { + state.locks = 0; + state.needsRestore = true; + tryRestoreAi(world, uuid, state, states); + } else { + boolean originalDisabled = mob.getCommandTags().contains("afw_noai_orig"); + mob.setAiDisabled(originalDisabled); + clearNoAiTags(mob); + } + } + } + } + + if (states != null && states.isEmpty()) { + AI_DISABLE_STATE_BY_WORLD.remove(key); + } + } + } + + private static boolean isWithinStartupNoAiCleanupWindow(ServerWorld world) { + if (world != null && world.getServer() != null) { + int now = world.getServer().getTicks(); + return now <= STARTUP_NOAI_CLEANUP_UNTIL_TICK; + } else { + return false; + } + } + + private static boolean isWithinStartupStartBlockWindow(ServerWorld world) { + if (world != null && world.getServer() != null) { + int now = world.getServer().getTicks(); + return now <= STARTUP_START_BLOCK_UNTIL_TICK; + } else { + return false; + } + } + + private static List sanitizeActorKeys(List actorKeys, int actorCount) { + if (actorKeys != null && !actorKeys.isEmpty()) { + return actorKeys.size() != actorCount ? List.of() : List.copyOf(actorKeys); + } else { + return List.of(); + } + } + + private static Map sanitizeMetadata(@Nullable Map metadata) { + if (metadata != null && !metadata.isEmpty()) { + LinkedHashMap out = new LinkedHashMap<>(); + + for (Entry entry : metadata.entrySet()) { + if (entry != null) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key != null && !key.isBlank() && value != null) { + out.put(key, value); + } + } + } + + return out.isEmpty() ? Map.of() : Map.copyOf(out); + } else { + return Map.of(); + } + } + + private static AnimationStartContext sanitizeStartContext(@Nullable AnimationStartContext context) { + return context == null + ? AnimationStartContext.empty() + : new AnimationStartContext(sanitizeMetadata(context.addonMetadata()), context.sessionData(), context.joinPredecessor(), context.queueContinuation()); + } + + private static void registerActiveInstance(UUID instanceId, ServerAnimationController.ActiveInstance instance) { + ACTIVE_INSTANCES.put(instanceId, instance); + Map actorIndex = ACTIVE_INSTANCE_BY_ACTOR.computeIfAbsent(instance.world.getRegistryKey(), ignored -> new ConcurrentHashMap<>()); + + for (UUID actorUuid : instance.actorUuidSet) { + actorIndex.put(actorUuid, instanceId); + } + } + + @Nullable + private static ServerAnimationController.ActiveInstance removeActiveInstance(UUID instanceId) { + ServerAnimationController.ActiveInstance removed = ACTIVE_INSTANCES.remove(instanceId); + if (removed == null) { + return null; + } + + Map actorIndex = ACTIVE_INSTANCE_BY_ACTOR.get(removed.world.getRegistryKey()); + if (actorIndex != null) { + for (UUID actorUuid : removed.actorUuidSet) { + actorIndex.remove(actorUuid, instanceId); + } + + if (actorIndex.isEmpty()) { + ACTIVE_INSTANCE_BY_ACTOR.remove(removed.world.getRegistryKey(), actorIndex); + } + } + + return removed; + } + + @Nullable + public static UUID findActiveInstanceId(ServerWorld world, UUID actorUuid) { + if (world != null && actorUuid != null) { + Map actorIndex = ACTIVE_INSTANCE_BY_ACTOR.get(world.getRegistryKey()); + if (actorIndex == null) { + return null; + } + + UUID instanceId = actorIndex.get(actorUuid); + if (instanceId == null) { + return null; + } + + ServerAnimationController.ActiveInstance instance = ACTIVE_INSTANCES.get(instanceId); + if (instance != null && instance.world == world && instance.actorUuidSet.contains(actorUuid)) { + return instanceId; + } + + actorIndex.remove(actorUuid, instanceId); + return null; + } else { + return null; + } + } + + public static boolean isActorActive(ServerWorld world, UUID actorUuid) { + return findActiveInstanceId(world, actorUuid) != null; + } + + private static void clearNoAiTags(MobEntity mob) { + mob.removeCommandTag("afw_noai"); + mob.removeCommandTag("afw_noai_orig"); + } + + private static void restoreAiState(MobEntity mob, ServerAnimationController.AiDisableState state) { + mob.setVelocity(Vec3d.ZERO); + mob.fallDistance = 0.0; + mob.setAiDisabled(state.originalAiDisabled); + clearNoAiTags(mob); + } + + private static void acquirePlayerLocks(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + Map states = PLAYER_LOCK_STATE_BY_WORLD.computeIfAbsent(world.getRegistryKey(), k -> new HashMap<>()); + + for (UUID uuid : actorUuids) { + if (world.getEntity(uuid) instanceof ServerPlayerEntity player) { + ServerAnimationController.PlayerLockState existing = states.get(uuid); + if (existing == null) { + states.put(uuid, new ServerAnimationController.PlayerLockState(player.hasNoGravity())); + } else { + existing.locks++; + } + + player.setNoGravity(true); + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0.0; + } + } + } + } + + private static void releasePlayerLocks(ServerWorld world, List actorUuids) { + if (world != null && actorUuids != null && !actorUuids.isEmpty()) { + Map states = PLAYER_LOCK_STATE_BY_WORLD.get(world.getRegistryKey()); + if (states != null && !states.isEmpty()) { + for (UUID uuid : actorUuids) { + ServerAnimationController.PlayerLockState state = states.get(uuid); + if (state != null) { + if (state.locks > 1) { + state.locks--; + } else { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(uuid); + if (player != null) { + player.setNoGravity(state.originalNoGravity); + player.fallDistance = 0.0; + } + + states.remove(uuid); + } + } + } + + if (states.isEmpty()) { + PLAYER_LOCK_STATE_BY_WORLD.remove(world.getRegistryKey()); + } + } + } + } + + private static void clearAllPlayerLocksInWorld(ServerWorld world) { + if (world != null) { + Map states = PLAYER_LOCK_STATE_BY_WORLD.remove(world.getRegistryKey()); + if (states != null && !states.isEmpty()) { + for (Entry entry : states.entrySet()) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(entry.getKey()); + if (player != null) { + player.setNoGravity(entry.getValue().originalNoGravity); + player.fallDistance = 0.0; + } + } + } + } + } + + private static void cleanupStalePlayerLocks(ServerWorld world, Set currentlyLockedPlayers) { + if (world != null) { + Set lockedNow = currentlyLockedPlayers == null ? Set.of() : currentlyLockedPlayers; + Map states = PLAYER_LOCK_STATE_BY_WORLD.get(world.getRegistryKey()); + if (states != null && !states.isEmpty()) { + Iterator> it = states.entrySet().iterator(); + + while (it.hasNext()) { + Entry entry = it.next(); + UUID uuid = entry.getKey(); + if (!lockedNow.contains(uuid) && !isActorActive(world, uuid)) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(uuid); + if (player != null) { + player.setNoGravity(entry.getValue().originalNoGravity); + player.fallDistance = 0.0; + } + + it.remove(); + } + } + + if (states.isEmpty()) { + PLAYER_LOCK_STATE_BY_WORLD.remove(world.getRegistryKey()); + } + } + + for (ServerPlayerEntity player : world.getPlayers()) { + UUID uuid = player.getUuid(); + if (!lockedNow.contains(uuid) && !isActorActive(world, uuid) && player.hasNoGravity() && !player.isSpectator() && !player.getAbilities().flying) { + player.setNoGravity(false); + player.fallDistance = 0.0; + } + } + } + } + + private static void lockActivePlayers(ServerWorld world, Map playersToLock, Set underwaterPlayers) { + if (!playersToLock.isEmpty()) { + for (Entry entry : playersToLock.entrySet()) { + if (world.getEntity(entry.getKey()) instanceof ServerPlayerEntity player) { + preparePlayerForAnimationTeleport(player); + ServerAnimationController.SharedTransform sharedTransform = entry.getValue(); + Vec3d targetPos = sharedTransform.pos(); + double dx = player.getX() - targetPos.x; + double dy = player.getY() - targetPos.y; + double dz = player.getZ() - targetPos.z; + if (dx * dx + dy * dy + dz * dz > 4.0E-4) { + player.setPosition(targetPos.x, targetPos.y, targetPos.z); + player.networkHandler.requestTeleport(targetPos.x, targetPos.y, targetPos.z, player.getYaw(), player.getPitch()); + } + + player.setNoGravity(true); + if (underwaterPlayers.contains(player.getUuid())) { + player.setAir(player.getMaxAir()); + } + + player.setVelocity(Vec3d.ZERO); + player.fallDistance = 0.0; + } + } + } + } + + private static void nudgeNonActiveMobsAwayFromAnimatedPlayers(ServerWorld world, Set activePlayerUuids, long nowTick) { + if (world != null && activePlayerUuids != null && !activePlayerUuids.isEmpty()) { + if (nowTick % 4L == 0L) { + for (UUID playerUuid : activePlayerUuids) { + ServerPlayerEntity player = Objects.requireNonNull(world.getServer()).getPlayerManager().getPlayer(playerUuid); + if (player != null && player.getEntityWorld() == world && player.isAlive() && !player.isRemoved()) { + Box searchBox = player.getBoundingBox().expand(1.0, 0.5, 1.0); + List nearby = world.getOtherEntities( + player, + searchBox, + entityx -> entityx instanceof MobEntity mobx && mobx.isAlive() && !mobx.isRemoved() && !isActorPendingOrActive(world, mobx.getUuid()) + ); + if (!nearby.isEmpty()) { + int nudged = 0; + + for (Entity entity : nearby) { + if (entity instanceof MobEntity mob && !isActorPendingOrActive(world, mob.getUuid())) { + if (nudged >= 6) { + break; + } + + if (nudgeMobAwayFromPlayer(player, mob)) { + nudged++; + } + } + } + } + } + } + } + } + } + + private static boolean nudgeMobAwayFromPlayer(ServerPlayerEntity player, MobEntity mob) { + double dx = mob.getX() - player.getX(); + double dz = mob.getZ() - player.getZ(); + double horizontalDistSq = dx * dx + dz * dz; + double minDistSq = 0.7224999999999999; + if (horizontalDistSq >= minDistSq) { + return false; + } + + Vec3d dir = resolveHorizontalNudgeDirection(mob, dx, dz, horizontalDistSq); + double dist = Math.sqrt(Math.max(horizontalDistSq, 1.0E-6)); + double overlap = Math.max(0.0, 0.85 - dist); + double strength = 0.08 * (0.6 + overlap / 0.85); + mob.addVelocity(dir.x * strength, 0.0, dir.z * strength); + mob.fallDistance = 0.0; + return true; + } + + private static Vec3d resolveHorizontalNudgeDirection(MobEntity mob, double dx, double dz, double horizontalDistSq) { + if (horizontalDistSq > 1.0E-6) { + double invLen = 1.0 / Math.sqrt(horizontalDistSq); + return new Vec3d(dx * invLen, 0.0, dz * invLen); + } else { + long bits = mob.getUuid().getLeastSignificantBits() ^ mob.getUuid().getMostSignificantBits(); + double angle = (bits & 65535L) * 9.587379924285257E-5; + return new Vec3d(Math.cos(angle), 0.0, Math.sin(angle)); + } + } + + private static void clearAttackTargets(ServerWorld world, Set actorUuids) { + if (!actorUuids.isEmpty()) { + for (Entity entity : world.iterateEntities()) { + if (entity instanceof MobEntity mob) { + LivingEntity target = mob.getTarget(); + if (target != null && actorUuids.contains(target.getUuid())) { + mob.setTarget(null); + mob.getNavigation().stop(); + } + } + } + } + } + + private static double clampSpeed(double speed) { + return !Double.isNaN(speed) && !Double.isInfinite(speed) && !(speed <= 0.0) ? Math.max(0.1, Math.min(speed, 4.0)) : 1.0; + } + + private static double resolveStageSpeed(List stages, int index) { + if (stages != null && !stages.isEmpty()) { + int clamped = Math.max(0, Math.min(index, stages.size() - 1)); + double speed = stages.get(clamped).speed(); + return !Double.isNaN(speed) && !Double.isInfinite(speed) && !(speed <= 0.0) ? speed : 1.0; + } else { + return 1.0; + } + } + + private static double sanitizeMultiplier(double multiplier) { + return !Double.isNaN(multiplier) && !Double.isInfinite(multiplier) && !(multiplier <= 0.0) ? Math.max(0.1, Math.min(multiplier, 4.0)) : 1.0; + } + + private static boolean maybeAdvanceStages( + ServerWorld world, + UUID instanceId, + ServerAnimationController.ActiveInstance inst, + long now, + List stageAdvances, + List toRemove + ) { + List stages = inst.stages; + ServerAnimationController.StageTracker tracker = inst.stageTracker; + if (stages != null && !stages.isEmpty() && tracker != null) { + int idx = Math.max(0, Math.min(tracker.stageIndex, stages.size() - 1)); + long stageStart = tracker.stageStartTick; + double speed = clampSpeed(inst.speed); + + while (true) { + AnimationStageInfo current = stages.get(idx); + long duration = AnimationStageTiming.resolveDurationTicks(inst.animationId, current, speed); + if (duration < 0L) { + return false; + } + + long targetEnd = stageStart + duration; + if (now < targetEnd) { + return false; + } + + if (idx + 1 >= stages.size()) { + long stopTick = now + 1L; + boolean chained = false; + if (inst.playerUuid != null) { + chained = startNextQueuedForPlayerDeferringRestore(world, inst, instanceId); + } + + StopAnimationS2CPayload payload = new StopAnimationS2CPayload(instanceId, stopTick); + + for (ServerPlayerEntity p : computeInstanceBroadcastTargets(world, null, inst)) { + ServerPlayNetworking.send(p, payload); + } + + fireStopEvent(instanceId, inst); + restoreTransforms(inst, chained && inst.playerUuid != null ? Set.of(inst.playerUuid) : null); + restoreAiForActors(world, inst.actorUuids); + releasePlayerLocks(world, inst.actorUuids); + toRemove.add(instanceId); + if (chained) { + sendOrLogDebug(world, null, NonDebugChatCategory.INFO, dbg("queue_continued_for_player"), false); + } + + return true; + } + + idx++; + stageStart = targetEnd; + tracker.stageIndex = idx; + tracker.stageStartTick = stageStart; + tracker.progressEndTickOverride = -1L; + inst.speed = clampSpeed(resolveStageSpeed(stages, idx)); + speed = clampSpeed(inst.speed); + long nextStageEnd = resolveStageEndTick(inst.animationId, stages.get(idx), stageStart, speed); + stageAdvances.add(new ServerAnimationController.StageAdvance(instanceId, stageStart, nextStageEnd, idx)); + } + } else { + return false; + } + } + + private static long resolveStageEndTick(ServerAnimationController.ActiveInstance instance) { + return instance != null && instance.stageTracker != null + ? resolveStageEndTick(instance.animationId, resolveCurrentStage(instance), instance.stageTracker.stageStartTick, instance.speed) + : -1L; + } + + private static long resolvePublishedStageEndTick(ServerAnimationController.ActiveInstance instance) { + if (instance != null && instance.stageTracker != null) { + long override = instance.stageTracker.progressEndTickOverride; + return override > instance.stageTracker.stageStartTick ? override : resolveStageEndTick(instance); + } else { + return -1L; + } + } + + private static long resolveStageEndTick(Identifier animationId, AnimationStageInfo stage, long stageStartTick, double speed) { + long duration = AnimationStageTiming.resolveDurationTicks(animationId, stage, speed); + return duration < 0L ? -1L : stageStartTick + duration; + } + + private static AnimationStageInfo resolveCurrentStage(ServerAnimationController.ActiveInstance inst) { + if (inst.stages != null && !inst.stages.isEmpty()) { + int idx = inst.stageTracker == null ? 0 : Math.max(0, Math.min(inst.stageTracker.stageIndex, inst.stages.size() - 1)); + return inst.stages.get(idx); + } else { + return new AnimationStageInfo(inst.animationId, true, 0L, true, 1.0); + } + } + + @Nullable + public static AnimationStageInfo getCurrentStage(ServerWorld world, UUID instanceId) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + return inst != null && inst.world == world ? resolveCurrentStage(inst) : null; + } + + @Nullable + public static ServerAnimationController.ActiveInstanceSnapshot getActiveInstanceSnapshot(ServerWorld world, UUID instanceId) { + ServerAnimationController.ActiveInstance inst = ACTIVE_INSTANCES.get(instanceId); + return inst != null && inst.world == world ? snapshot(instanceId, inst) : null; + } + + public static List getActiveInstanceSnapshots(ServerWorld world) { + if (world != null && !ACTIVE_INSTANCES.isEmpty()) { + List snapshots = new ArrayList<>(); + + for (Entry entry : ACTIVE_INSTANCES.entrySet()) { + if (entry.getValue().world == world) { + snapshots.add(snapshot(entry.getKey(), entry.getValue())); + } + } + + return List.copyOf(snapshots); + } else { + return List.of(); + } + } + + private static ServerAnimationController.ActiveInstanceSnapshot snapshot(UUID instanceId, ServerAnimationController.ActiveInstance instance) { + int stageIndex = instance.stageTracker == null ? 0 : instance.stageTracker.stageIndex; + long stageStartTick = instance.stageTracker == null ? instance.startTick : instance.stageTracker.stageStartTick; + return new ServerAnimationController.ActiveInstanceSnapshot( + instanceId, + instance.animationId, + List.copyOf(instance.actorUuids), + sanitizeActorKeys(instance.actorKeys, instance.actorUuids.size()), + List.copyOf(instance.stages), + instance.startTick, + stageIndex, + stageStartTick, + resolvePublishedStageEndTick(instance), + instance.speed, + instance.damageBehavior, + instance.ignoreAttackers, + instance.startContext + ); + } + + @Nullable + private static Float resolveTransitionYaw(@Nullable ServerAnimationController.ActiveInstance inst) { + return inst != null && inst.sharedTransform != null ? inst.sharedTransform.yaw() : null; + } + + private static final class ActiveInstance { + final ServerWorld world; + final Identifier animationId; + final List> modelRootsByActor; + final List actorUuids; + final List actorKeys; + final List stages; + final long startTick; + final Map originalTransforms; + final ServerAnimationController.SharedTransform sharedTransform; + final boolean lockOrientation; + final float lockedYaw; + final float lockedHeadYaw; + final float lockedPitch; + final NonAnimationDamageBehavior damageBehavior; + final boolean ignoreAttackers; + final AnimationStartContext startContext; + final boolean underwaterBreathing; + @Nullable + final UUID playerUuid; + final Set actorUuidSet; + final ServerAnimationController.StageTracker stageTracker; + @Nullable + final ServerAnimationController.BlockPlacement blockPlacement; + @Nullable + final Vec3d cameraOrbitTarget; + final Set subscribedPlayerUuids = ConcurrentHashMap.newKeySet(); + double speed; + + ActiveInstance( + ServerWorld world, + Identifier animationId, + List> modelRootsByActor, + List actorUuids, + List actorKeys, + List stages, + long startTick, + Map originalTransforms, + ServerAnimationController.SharedTransform sharedTransform, + boolean lockOrientation, + float lockedYaw, + float lockedHeadYaw, + float lockedPitch, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext, + boolean underwaterBreathing, + @Nullable UUID playerUuid, + Set actorUuidSet, + ServerAnimationController.StageTracker stageTracker, + @Nullable ServerAnimationController.BlockPlacement blockPlacement, + @Nullable Vec3d cameraOrbitTarget, + double speed + ) { + this.world = world; + this.animationId = animationId; + this.modelRootsByActor = modelRootsByActor.stream().map(List::copyOf).toList(); + this.actorUuids = actorUuids; + this.actorKeys = actorKeys; + this.stages = stages; + this.startTick = startTick; + this.originalTransforms = originalTransforms; + this.sharedTransform = sharedTransform; + this.lockOrientation = lockOrientation; + this.lockedYaw = lockedYaw; + this.lockedHeadYaw = lockedHeadYaw; + this.lockedPitch = lockedPitch; + this.damageBehavior = damageBehavior; + this.ignoreAttackers = ignoreAttackers; + this.startContext = startContext; + this.underwaterBreathing = underwaterBreathing; + this.playerUuid = playerUuid; + this.actorUuidSet = actorUuidSet; + this.stageTracker = stageTracker; + this.blockPlacement = blockPlacement; + this.cameraOrbitTarget = cameraOrbitTarget; + this.speed = speed; + } + } + + public record ActiveInstanceSnapshot( + UUID instanceId, + Identifier animationId, + List actorUuids, + List actorKeys, + List stages, + long startTick, + int stageIndex, + long stageStartTick, + long stageEndTick, + double speed, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext + ) { + public Map metadata() { + return this.startContext.addonMetadata(); + } + + @Nullable + public AnimationSessionData sessionData() { + return this.startContext.sessionData(); + } + } + + private static final class AiDisableState { + final boolean originalAiDisabled; + int locks; + boolean needsRestore; + float lockedYaw; + float lockedHeadYaw; + float lockedBodyYaw; + float lockedPitch; + + AiDisableState(boolean originalAiDisabled) { + this.originalAiDisabled = originalAiDisabled; + this.locks = 1; + this.needsRestore = false; + this.lockedYaw = 0.0F; + this.lockedHeadYaw = 0.0F; + this.lockedBodyYaw = 0.0F; + this.lockedPitch = 0.0F; + } + } + + private record AnimationStartProtection(RegistryKey worldKey, long untilTick) { + } + + private record BedFootprintFacing(boolean bedFootprint, @Nullable Direction facing) { + static ServerAnimationController.BedFootprintFacing none() { + return new ServerAnimationController.BedFootprintFacing(false, null); + } + + static ServerAnimationController.BedFootprintFacing invalid() { + return new ServerAnimationController.BedFootprintFacing(true, null); + } + + static ServerAnimationController.BedFootprintFacing valid(Direction facing) { + return new ServerAnimationController.BedFootprintFacing(true, facing); + } + } + + private record BlockPlacement( + BlockPos wallPos, Direction facing, ServerAnimationController.SharedTransform sharedTransform, @Nullable Vec3d cameraOrbitTarget, boolean centerSupport + ) { + } + + private record DeferredQueueContext( + ServerAnimationController.OriginalTransform originalTransform, @Nullable ServerAnimationController.BlockPlacement previousPlacement + ) { + } + + private record EligibleDefinition( + AnimationDefinitions.Definition definition, List actorKeys, @Nullable ServerAnimationController.BlockPlacement blockPlacement + ) { + } + + private record FloorHit(boolean found, Vec3d pos) { + } + + private record GroundPlacementOffset(double dx, double dz, double distSq) { + } + + public record MatchedStartRequest(@Nullable UUID instanceId, Identifier animationId, List actorKeys, List stages) { + } + + private record OriginalTransform(RegistryKey worldKey, Vec3d pos, float yaw, float pitch, float headYaw, float bodyYaw, @Nullable Vec3d restorePos) { + private OriginalTransform(RegistryKey worldKey, Vec3d pos, float yaw, float pitch, float headYaw, float bodyYaw) { + this(worldKey, pos, yaw, pitch, headYaw, bodyYaw, null); + } + + private Vec3d restorePosOrPos() { + return this.restorePos == null ? this.pos : this.restorePos; + } + } + + private static final class PlayerLockState { + final boolean originalNoGravity; + int locks; + + PlayerLockState(boolean originalNoGravity) { + this.originalNoGravity = originalNoGravity; + this.locks = 1; + } + } + + private static final class PlayerQueueState { + final UUID playerUuid; + final Deque entries = new ArrayDeque<>(); + + PlayerQueueState(UUID playerUuid) { + this.playerUuid = playerUuid; + } + } + + private enum QueueStartResult { + STARTED, + RETRY, + SKIP; + } + + private record QueuedEntry( + UUID entryId, + Identifier animationId, + List actorUuids, + NonAnimationDamageBehavior damageBehavior, + boolean ignoreAttackers, + AnimationStartContext startContext + ) { + } + + private record RecentNoRestoreTransition( + long expiresTick, ServerAnimationController.SharedTransform sharedTransform, @Nullable ServerAnimationController.BlockPlacement blockPlacement + ) { + } + + private record RequirementCheck(boolean valid, @Nullable ServerAnimationController.BlockPlacement blockPlacement) { + } + + private record SharedTransform(Vec3d pos, float yaw, float pitch, float headYaw, float bodyYaw) { + } + + private record StageAdvance(UUID instanceId, long advanceTick, long stageEndTick, int stageIndex) { + } + + private static final class StageTracker { + int stageIndex = 0; + long stageStartTick; + long progressEndTickOverride; + + StageTracker(long stageStartTick) { + this.stageStartTick = stageStartTick; + this.progressEndTickOverride = -1L; + } + } + + private enum StopReason { + NATURAL_END, + EXTERNAL_STOP, + DAMAGE_STOP, + JOIN_REPLACE, + ACTOR_DEATH, + STOP_ALL; + } + + private record WallColumn(BlockPos pos, Vec3d surfaceAnchor) { + } + + private record WallSegment(List columns, Vec3d surfaceCenter) { + } +} diff --git a/src/main/java/com/nonid/internal/animation/util/AnimationIds.java b/src/main/java/com/nonid/internal/animation/util/AnimationIds.java new file mode 100644 index 0000000..f52bf4e --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/util/AnimationIds.java @@ -0,0 +1,74 @@ +package com.nonid.internal.animation.util; + +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class AnimationIds { + private AnimationIds() { + } + + @Nullable + public static Identifier baseId(@Nullable Identifier animationId) { + if (animationId == null) { + return null; + } + + Integer stage = stageNumber(animationId); + if (stage == null) { + return animationId; + } + + String path = animationId.getPath(); + return Identifier.of(animationId.getNamespace(), path.substring(0, path.lastIndexOf(".p"))); + } + + @Nullable + public static Integer stageNumber(@Nullable Identifier animationId) { + if (animationId == null) { + return null; + } + + String path = animationId.getPath(); + int marker = path.lastIndexOf(".p"); + if (marker > 0 && marker + 2 < path.length()) { + String raw = path.substring(marker + 2); + int value = 0; + + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c < '0' || c > '9') { + return null; + } + + int digit = c - '0'; + if (value > (Integer.MAX_VALUE - digit) / 10) { + return null; + } + + value = value * 10 + digit; + } + + return value > 0 ? value : null; + } else { + return null; + } + } + + public static int stageNumberOrDefault(@Nullable Identifier animationId, int fallback) { + Integer stage = stageNumber(animationId); + return stage == null ? fallback : stage; + } + + public static Identifier stageId(Identifier animationId, int stage) { + if (animationId == null) { + throw new IllegalArgumentException("animationId cannot be null"); + } + + if (stage <= 0) { + throw new IllegalArgumentException("stage must be positive"); + } + + Identifier base = baseId(animationId); + return Identifier.of(base.getNamespace(), base.getPath() + ".p" + stage); + } +} diff --git a/src/main/java/com/nonid/internal/animation/util/EntityVariants.java b/src/main/java/com/nonid/internal/animation/util/EntityVariants.java new file mode 100644 index 0000000..c83152f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/util/EntityVariants.java @@ -0,0 +1,30 @@ +package com.nonid.internal.animation.util; + +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.SlimeEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class EntityVariants { + private EntityVariants() { + } + + @Nullable + public static String resolveVariant(Entity entity) { + return entity instanceof SlimeEntity slime ? "size_" + Math.max(0, slime.getSize() - 1) : null; + } + + @Nullable + public static Identifier resolveVariantModel(LivingEntity entity, @Nullable Identifier baseModelId) { + String variant = resolveVariant(entity); + return variant == null ? null : variantModelId(baseModelId, variant); + } + + @Nullable + public static Identifier variantModelId(@Nullable Identifier baseModelId, @Nullable String variant) { + return baseModelId != null && variant != null && !variant.isBlank() + ? Identifier.of(baseModelId.getNamespace(), baseModelId.getPath() + "_" + variant) + : null; + } +} diff --git a/src/main/java/com/nonid/internal/animation/util/StageTimeWarp.java b/src/main/java/com/nonid/internal/animation/util/StageTimeWarp.java new file mode 100644 index 0000000..c89f63c --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/util/StageTimeWarp.java @@ -0,0 +1,78 @@ +package com.nonid.internal.animation.util; + +public final class StageTimeWarp { + private static final double EPSILON_TICKS = 1.0E-4; + + private StageTimeWarp() { + } + + public static double offsetSecondsToTicks(double offsetSeconds) { + return !Double.isFinite(offsetSeconds) ? 0.0 : offsetSeconds * 20.0; + } + + public static double clampOffsetSeconds(double cycleTicks, double offsetSeconds) { + return clampOffsetTicks(cycleTicks, offsetSecondsToTicks(offsetSeconds)) / 20.0; + } + + public static double clampOffsetTicks(double cycleTicks, double offsetTicks) { + if (!Double.isFinite(cycleTicks) || cycleTicks <= 0.0) { + return 0.0; + } + + if (!Double.isFinite(offsetTicks)) { + return 0.0; + } + + double midpoint = cycleTicks * 0.5; + double minOffset = -midpoint + 1.0E-4; + double maxOffset = midpoint - 1.0E-4; + return Math.max(minOffset, Math.min(maxOffset, offsetTicks)); + } + + public static boolean hasWarp(double cycleTicks, double offsetTicks) { + return Double.isFinite(cycleTicks) && !(cycleTicks <= 0.0) ? Math.abs(clampOffsetTicks(cycleTicks, offsetTicks)) > 1.0E-4 : false; + } + + public static double authoredToWarpedCycleTicks(double authoredTicks, double cycleTicks, double offsetTicks) { + if (!hasWarp(cycleTicks, offsetTicks)) { + return clampCyclePosition(authoredTicks, cycleTicks); + } + + double position = clampCyclePosition(authoredTicks, cycleTicks); + double midpoint = cycleTicks * 0.5; + double shiftedMidpoint = midpoint + clampOffsetTicks(cycleTicks, offsetTicks); + return position <= midpoint + ? position * (shiftedMidpoint / midpoint) + : shiftedMidpoint + (position - midpoint) * ((cycleTicks - shiftedMidpoint) / (cycleTicks - midpoint)); + } + + public static double warpedToAuthoredCycleTicks(double warpedTicks, double cycleTicks, double offsetTicks) { + if (!hasWarp(cycleTicks, offsetTicks)) { + return clampCyclePosition(warpedTicks, cycleTicks); + } + + double position = clampCyclePosition(warpedTicks, cycleTicks); + double midpoint = cycleTicks * 0.5; + double shiftedMidpoint = midpoint + clampOffsetTicks(cycleTicks, offsetTicks); + return position <= shiftedMidpoint + ? position * (midpoint / shiftedMidpoint) + : midpoint + (position - shiftedMidpoint) * ((cycleTicks - midpoint) / (cycleTicks - shiftedMidpoint)); + } + + private static double clampCyclePosition(double ticks, double cycleTicks) { + if (!Double.isFinite(cycleTicks) || cycleTicks <= 0.0) { + return 0.0; + } + + if (!Double.isFinite(ticks)) { + return 0.0; + } + + double position = ticks % cycleTicks; + if (position < 0.0) { + position += cycleTicks; + } + + return position > cycleTicks ? cycleTicks : position; + } +} diff --git a/src/main/java/com/nonid/internal/animation/util/WeightedPicker.java b/src/main/java/com/nonid/internal/animation/util/WeightedPicker.java new file mode 100644 index 0000000..cf0127f --- /dev/null +++ b/src/main/java/com/nonid/internal/animation/util/WeightedPicker.java @@ -0,0 +1,53 @@ +package com.nonid.internal.animation.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.ToDoubleFunction; +import org.jetbrains.annotations.Nullable; + +public final class WeightedPicker { + private WeightedPicker() { + } + + @Nullable + public static T pick(List candidates, ToDoubleFunction weight, double randomUnit) { + if (candidates != null && !candidates.isEmpty()) { + List valid = new ArrayList<>(candidates.size()); + double total = 0.0; + + for (T candidate : candidates) { + if (candidate != null) { + valid.add(candidate); + total += sanitizeWeight(weight.applyAsDouble(candidate)); + } + } + + if (valid.isEmpty()) { + return null; + } + + double unit = Double.isFinite(randomUnit) ? Math.max(0.0, Math.min(Math.nextDown(1.0), randomUnit)) : 0.0; + if (Double.isFinite(total) && !(total <= 0.0)) { + double roll = unit * total; + double running = 0.0; + + for (T candidate : valid) { + running += sanitizeWeight(weight.applyAsDouble(candidate)); + if (roll < running) { + return candidate; + } + } + + return valid.getLast(); + } else { + return valid.get(Math.min(valid.size() - 1, (int)Math.floor(unit * valid.size()))); + } + } else { + return null; + } + } + + public static double sanitizeWeight(double value) { + return Double.isFinite(value) && value > 0.0 ? value : 1.0; + } +} diff --git a/src/main/java/com/nonid/item/EnergyGainAdjustItem.java b/src/main/java/com/nonid/item/EnergyGainAdjustItem.java new file mode 100644 index 0000000..3f00b1f --- /dev/null +++ b/src/main/java/com/nonid/item/EnergyGainAdjustItem.java @@ -0,0 +1,88 @@ +package com.nonid.item; + +import com.nonid.EnergyHolder; +import com.nonid.particle.NonParticles; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Item.Settings; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; + +public class EnergyGainAdjustItem extends Item { + private final float delta; + + public EnergyGainAdjustItem(Settings settings, float delta) { + super(settings); + this.delta = delta; + } + + public ActionResult useOnEntity(ItemStack stack, PlayerEntity user, LivingEntity entity, Hand hand) { + if (!(entity instanceof MobEntity mob && mob instanceof EnergyHolder holder)) { + return ActionResult.PASS; + } else { + if (user.getEntityWorld().isClient()) { + return ActionResult.SUCCESS; + } + + boolean removedStabilization = false; + if (this.delta > 0.0F && mob.getCommandTags().contains("non.energy_stabilized")) { + mob.removeCommandTag("non.energy_stabilized"); + removedStabilization = true; + } + + float current = holder.getEnergyGainMultiplier(); + float target = clamp(current + this.delta, 0.4F, 6.0F); + if (Math.abs(target - current) < 1.0E-6F && !removedStabilization) { + return ActionResult.FAIL; + } + + if (Math.abs(target - current) >= 1.0E-6F) { + holder.setEnergyGainMultiplier(target); + } + + if (user.getEntityWorld() instanceof ServerWorld serverWorld) { + this.playFeedback(serverWorld, mob, removedStabilization, target); + } + + if (!user.isInCreativeMode()) { + stack.decrement(1); + } + + return ActionResult.SUCCESS; + } + } + + private void playFeedback(ServerWorld world, MobEntity mob, boolean removedStabilization, float appliedMultiplier) { + if (removedStabilization) { + world.spawnParticles(ParticleTypes.WITCH, mob.getX(), mob.getBodyY(0.6), mob.getZ(), 12, 0.35, 0.35, 0.35, 0.01); + world.playSound(null, mob.getX(), mob.getY(), mob.getZ(), SoundEvents.BLOCK_BEACON_DEACTIVATE, SoundCategory.NEUTRAL, 0.9F, 0.95F); + } else { + boolean augmenter = this.delta > 0.0F; + ParticleEffect particle = augmenter ? NonParticles.SMALLHEART : ParticleTypes.SMOKE; + SoundEvent sound = augmenter ? SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP : SoundEvents.BLOCK_BREWING_STAND_BREW; + float pitch = pitchForMultiplier(appliedMultiplier); + world.spawnParticles(particle, mob.getX(), mob.getBodyY(0.6), mob.getZ(), 10, 0.35, 0.35, 0.35, 0.01); + world.playSound(null, mob.getX(), mob.getY(), mob.getZ(), sound, SoundCategory.NEUTRAL, 0.9F, pitch); + } + } + + private static float pitchForMultiplier(float multiplier) { + float clamped = clamp(multiplier, 0.4F, 6.0F); + float span = 5.6F; + float t = (clamped - 0.4F) / span; + return 0.65F + 0.9F * t; + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/src/main/java/com/nonid/item/EnergyStabilizerItem.java b/src/main/java/com/nonid/item/EnergyStabilizerItem.java new file mode 100644 index 0000000..e066317 --- /dev/null +++ b/src/main/java/com/nonid/item/EnergyStabilizerItem.java @@ -0,0 +1,128 @@ +package com.nonid.item; + +import com.nonid.EnergyHolder; +import com.nonid.NeedsOfNature; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.NbtComponent; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.Angerable; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Item.Settings; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.registry.Registries; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public class EnergyStabilizerItem extends Item { + private static final String STABILIZER_ENTITY_DATA_KEY = "stabilizer_entity"; + + public EnergyStabilizerItem(Settings settings) { + super(settings); + } + + public Text getName(ItemStack stack) { + Identifier target = getTargetEntityTypeId(stack); + if (target == null) { + return super.getName(stack); + } + + Text entityName = Registries.ENTITY_TYPE.getOptionalValue(target).map(EntityType::getName).orElseGet(() -> Text.literal(target.toString())); + return Text.translatable("item.needsofnature.entity_energy_stabilizer", new Object[]{entityName}); + } + + public ActionResult useOnEntity(ItemStack stack, PlayerEntity user, LivingEntity entity, Hand hand) { + if (entity instanceof MobEntity mob && mob instanceof EnergyHolder) { + Identifier target = getTargetEntityTypeId(stack); + if (target == null) { + return ActionResult.FAIL; + } + + Identifier mobType = Registries.ENTITY_TYPE.getId(mob.getType()); + if (!target.equals(mobType)) { + return ActionResult.FAIL; + } + + if (mob.getCommandTags().contains("non.energy_stabilized")) { + return ActionResult.FAIL; + } + + if (user.getEntityWorld().isClient()) { + return ActionResult.SUCCESS; + } + + EnergyHolder holder = (EnergyHolder)mob; + mob.addCommandTag("non.energy_stabilized"); + holder.setEnergy(Math.min(holder.getEnergy(), 199)); + mob.setTarget(null); + if (mob instanceof Angerable angerable) { + angerable.stopAnger(); + } + + if (user.getEntityWorld() instanceof ServerWorld serverWorld) { + playFeedback(serverWorld, mob); + } + + if (!user.isInCreativeMode()) { + stack.decrement(1); + } + + return ActionResult.SUCCESS; + } else { + return ActionResult.PASS; + } + } + + public static ItemStack createStackForEntity(@Nullable Identifier entityTypeId) { + ItemStack stack = new ItemStack(NeedsOfNature.ENERGY_STABILIZER); + if (entityTypeId != null) { + setTargetEntityTypeId(stack, entityTypeId); + } + + return stack; + } + + public static void setTargetEntityTypeId(ItemStack stack, @Nullable Identifier entityTypeId) { + if (stack != null && !stack.isEmpty()) { + NbtComponent.set(DataComponentTypes.CUSTOM_DATA, stack, nbt -> { + String value = entityTypeId == null ? "" : entityTypeId.toString(); + nbt.putString("stabilizer_entity", value); + }); + } + } + + @Nullable + public static Identifier getTargetEntityTypeId(ItemStack stack) { + if (stack != null && !stack.isEmpty() && stack.isOf(NeedsOfNature.ENERGY_STABILIZER)) { + NbtComponent custom = (NbtComponent)stack.get(DataComponentTypes.CUSTOM_DATA); + if (custom != null && !custom.isEmpty()) { + NbtCompound nbt = custom.copyNbt(); + if (!nbt.contains("stabilizer_entity")) { + return null; + } + + String raw = nbt.getString("stabilizer_entity", ""); + return raw != null && !raw.isBlank() ? Identifier.tryParse(raw) : null; + } else { + return null; + } + } else { + return null; + } + } + + private static void playFeedback(ServerWorld world, MobEntity mob) { + world.spawnParticles(ParticleTypes.END_ROD, mob.getX(), mob.getBodyY(0.65), mob.getZ(), 12, 0.4, 0.4, 0.4, 0.02); + world.playSound(null, mob.getX(), mob.getY(), mob.getZ(), SoundEvents.BLOCK_BEACON_POWER_SELECT, SoundCategory.NEUTRAL, 0.9F, 1.0F); + } +} diff --git a/src/main/java/com/nonid/item/HorseLiquidCollectorItem.java b/src/main/java/com/nonid/item/HorseLiquidCollectorItem.java new file mode 100644 index 0000000..ca03e7f --- /dev/null +++ b/src/main/java/com/nonid/item/HorseLiquidCollectorItem.java @@ -0,0 +1,98 @@ +package com.nonid.item; + +import com.nonid.NeedsOfNature; +import com.nonid.client.HorseLiquidCollectorItemRenderer; +import com.nonid.entity.HorseLiquidCollectorEntity; +import java.util.function.Consumer; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Item.Settings; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.stat.Stats; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult.Type; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import net.minecraft.world.RaycastContext.FluidHandling; +import net.minecraft.world.event.GameEvent; +import org.jetbrains.annotations.NotNull; +import software.bernie.geckolib.animatable.GeoItem; +import software.bernie.geckolib.animatable.client.GeoRenderProvider; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animatable.manager.AnimatableManager.ControllerRegistrar; +import software.bernie.geckolib.renderer.GeoItemRenderer; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class HorseLiquidCollectorItem extends Item implements GeoItem { + private AnimatableInstanceCache animatableCache; + private boolean syncedAnimatableRegistered; + + public HorseLiquidCollectorItem(Settings settings) { + super(settings); + } + + public ActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + BlockHitResult hit = raycast(world, user, FluidHandling.ANY); + if (hit.getType() != Type.BLOCK) { + return ActionResult.PASS; + } + + Vec3d hitPos = hit.getPos(); + HorseLiquidCollectorEntity collector = new HorseLiquidCollectorEntity(NeedsOfNature.HORSE_LIQUID_COLLECTOR_ENTITY_TYPE, world); + collector.refreshPositionAndAngles(hitPos.x, hitPos.y + 0.01, hitPos.z, user.getYaw(), 0.0F); + collector.setLockedOrientation(user.getYaw(), 0.0F); + if (!world.isSpaceEmpty(collector, collector.getBoundingBox())) { + return ActionResult.FAIL; + } + + if (world.isClient()) { + return ActionResult.SUCCESS; + } + + world.spawnEntity(collector); + world.playSound(null, collector.getX(), collector.getY(), collector.getZ(), SoundEvents.ENTITY_ARMOR_STAND_PLACE, SoundCategory.PLAYERS, 1.0F, 1.0F); + world.emitGameEvent(user, GameEvent.ENTITY_PLACE, collector.getBlockPos()); + user.incrementStat(Stats.USED.getOrCreateStat(this)); + if (!user.isInCreativeMode()) { + stack.decrement(1); + } + + return ActionResult.SUCCESS; + } + + public void registerControllers(@NotNull ControllerRegistrar controllers) { + } + + @NotNull + public AnimatableInstanceCache getAnimatableInstanceCache() { + if (!this.syncedAnimatableRegistered) { + GeoItem.registerSyncedAnimatable(this); + this.syncedAnimatableRegistered = true; + } + + if (this.animatableCache == null) { + this.animatableCache = GeckoLibUtil.createInstanceCache(this); + } + + return this.animatableCache; + } + + public void createGeoRenderer(Consumer consumer) { + consumer.accept(new GeoRenderProvider() { + private GeoItemRenderer<@NotNull HorseLiquidCollectorItem> renderer; + + public GeoItemRenderer getGeoItemRenderer() { + if (this.renderer == null) { + this.renderer = new HorseLiquidCollectorItemRenderer(); + } + + return this.renderer; + } + }); + } +} diff --git a/src/main/java/com/nonid/item/NonDataAccessoryItem.java b/src/main/java/com/nonid/item/NonDataAccessoryItem.java new file mode 100644 index 0000000..4312940 --- /dev/null +++ b/src/main/java/com/nonid/item/NonDataAccessoryItem.java @@ -0,0 +1,45 @@ +package com.nonid.item; + +import com.nonid.data.NonAccessoryLiquidFilling; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.inventory.StackReference; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.item.Item.Settings; +import net.minecraft.screen.slot.Slot; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.ClickType; + +public final class NonDataAccessoryItem extends Item { + public NonDataAccessoryItem(Settings settings) { + super(settings); + } + + public boolean onClicked(ItemStack stack, ItemStack otherStack, Slot slot, ClickType clickType, PlayerEntity player, StackReference cursorStackReference) { + if (clickType != ClickType.LEFT || !slot.canTakePartial(player)) { + return false; + } + + if (!NonAccessoryLiquidFilling.fillInPlace(stack, otherStack)) { + return false; + } + + otherStack.decrement(1); + if (otherStack.isEmpty()) { + cursorStackReference.set(new ItemStack(Items.GLASS_BOTTLE)); + } else { + cursorStackReference.set(otherStack); + ItemStack glassBottle = new ItemStack(Items.GLASS_BOTTLE); + if (!player.getInventory().insertStack(glassBottle) && !player.getEntityWorld().isClient()) { + player.dropItem(glassBottle, false); + } + } + + if (player.getEntityWorld().isClient()) { + player.playSound(SoundEvents.ITEM_BOTTLE_FILL, 1.0F, 1.0F); + } + + return true; + } +} diff --git a/src/main/java/com/nonid/mixin/AnimalEntityBreedingMixin.java b/src/main/java/com/nonid/mixin/AnimalEntityBreedingMixin.java new file mode 100644 index 0000000..4bb726b --- /dev/null +++ b/src/main/java/com/nonid/mixin/AnimalEntityBreedingMixin.java @@ -0,0 +1,40 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import net.minecraft.entity.passive.AnimalEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(AnimalEntity.class) +public abstract class AnimalEntityBreedingMixin { + @Inject(method = "canBreedWith", at = @At("HEAD"), cancellable = true) + private void non$skipInvalidGenderPairWhenSelectingMate(AnimalEntity other, CallbackInfoReturnable cir) { + if (other != null) { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null && config.useAnimationBreeding() && config.requireMaleFemaleForBreeding()) { + AnimalEntity self = (AnimalEntity)(Object)this; + boolean selfMale = self.getCommandTags().contains("gender.male"); + boolean selfFemale = self.getCommandTags().contains("gender.female"); + boolean otherMale = other.getCommandTags().contains("gender.male"); + boolean otherFemale = other.getCommandTags().contains("gender.female"); + boolean validPair = selfMale && otherFemale || selfFemale && otherMale; + if (!validPair) { + cir.setReturnValue(false); + } + } + } + } + + @Inject(method = "breed(Lnet/minecraft/server/world/ServerWorld;Lnet/minecraft/entity/passive/AnimalEntity;)V", at = @At("HEAD"), cancellable = true) + private void non$overrideVanillaBreeding(ServerWorld world, AnimalEntity other, CallbackInfo ci) { + AnimalEntity self = (AnimalEntity)(Object)this; + if (NeedsOfNature.handleAnimationBreedingAttempt(world, self, other)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/com/nonid/mixin/AnimalMateGoalAnimationBreedingMixin.java b/src/main/java/com/nonid/mixin/AnimalMateGoalAnimationBreedingMixin.java new file mode 100644 index 0000000..465a1e8 --- /dev/null +++ b/src/main/java/com/nonid/mixin/AnimalMateGoalAnimationBreedingMixin.java @@ -0,0 +1,44 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import net.minecraft.entity.ai.goal.AnimalMateGoal; +import net.minecraft.entity.passive.AnimalEntity; +import net.minecraft.server.world.ServerWorld; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(AnimalMateGoal.class) +public abstract class AnimalMateGoalAnimationBreedingMixin { + @Final + @Shadow + protected AnimalEntity animal; + @Final + @Shadow + protected ServerWorld world; + @Shadow + @Nullable + protected AnimalEntity mate; + + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void non$startAnimationBreedingWhenClose(CallbackInfo ci) { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null && config.useAnimationBreeding()) { + AnimalEntity other = this.mate; + if (other != null && other.isAlive() && !other.isRemoved()) { + if (this.animal.isInLove() && other.isInLove()) { + if (!(this.animal.squaredDistanceTo(other) >= 9.0)) { + if (NeedsOfNature.handleAnimationBreedingAttempt(this.world, this.animal, other)) { + ci.cancel(); + } + } + } + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/BrewingRecipeRegistryMixin.java b/src/main/java/com/nonid/mixin/BrewingRecipeRegistryMixin.java new file mode 100644 index 0000000..4b5522a --- /dev/null +++ b/src/main/java/com/nonid/mixin/BrewingRecipeRegistryMixin.java @@ -0,0 +1,24 @@ +package com.nonid.mixin; + +import com.nonid.potion.NonPotions; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.recipe.BrewingRecipeRegistry; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(BrewingRecipeRegistry.class) +public abstract class BrewingRecipeRegistryMixin { + @Inject(method = "hasItemRecipe", at = @At("HEAD"), cancellable = true) + private void non$blockSplashConversionsForLiquidBottles(ItemStack input, ItemStack ingredient, CallbackInfoReturnable cir) { + if (NonPotions.isLiquidBottle(input)) { + if (ingredient != null && !ingredient.isEmpty()) { + if (ingredient.isOf(Items.GUNPOWDER) || ingredient.isOf(Items.DRAGON_BREATH)) { + cir.setReturnValue(false); + } + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/CreeperEntityNoNAnimationMixin.java b/src/main/java/com/nonid/mixin/CreeperEntityNoNAnimationMixin.java new file mode 100644 index 0000000..f8f5456 --- /dev/null +++ b/src/main/java/com/nonid/mixin/CreeperEntityNoNAnimationMixin.java @@ -0,0 +1,159 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.server.ServerAnimationController; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.mob.CreeperEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.Box; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(CreeperEntity.class) +public abstract class CreeperEntityNoNAnimationMixin { + @Unique + private static final double IGNORED_ACTOR_FUSE_RADIUS = 7.0; + @Shadow + private int currentFuseTime; + @Shadow + private int lastFuseTime; + @Shadow + private int fuseTime; + @Shadow + private static TrackedData FUSE_SPEED; + @Shadow + private static TrackedData IGNITED; + @Unique + private boolean non$wasAnimationControlled; + + @Inject(method = "tick", at = @At("HEAD")) + private void non$resetFuseAfterAnimationControlEnds(CallbackInfo ci) { + boolean controlled = this.non$isAnimationControlled(); + if (this.non$wasAnimationControlled && !controlled) { + this.non$resetFuse(); + } + + this.non$wasAnimationControlled = controlled; + if (!controlled && this.non$hasIgnoredAnimationActorNearby()) { + this.non$resetFuse(); + } + } + + @Inject(method = "getFuseSpeed", at = @At("HEAD"), cancellable = true) + private void non$stopFuseDuringAnimation(CallbackInfoReturnable cir) { + if (this.non$shouldSuppressFuse()) { + this.non$resetFuse(); + cir.setReturnValue(0); + } + } + + @Inject(method = "setFuseSpeed", at = @At("HEAD"), cancellable = true) + private void non$stopFuseSpeedDuringAnimation(int fuseSpeed, CallbackInfo ci) { + if (this.non$shouldSuppressFuse()) { + this.non$resetFuse(); + ci.cancel(); + } + } + + @Inject(method = "isIgnited", at = @At("HEAD"), cancellable = true) + private void non$ignoreIgnitedDuringAnimation(CallbackInfoReturnable cir) { + if (this.non$shouldSuppressFuse()) { + this.non$resetFuse(); + cir.setReturnValue(false); + } + } + + @Inject(method = "ignite", at = @At("HEAD"), cancellable = true) + private void non$ignoreManualIgnitionDuringAnimation(CallbackInfo ci) { + if (this.non$shouldSuppressFuse()) { + this.non$resetFuse(); + ci.cancel(); + } + } + + @Inject(method = "explode", at = @At("HEAD"), cancellable = true) + private void non$blockExplosionDuringGatherOrAnimation(CallbackInfo ci) { + if (this.non$shouldSuppressFuse()) { + this.non$resetFuse(); + ci.cancel(); + } else { + if (this.non$isNoNGathering()) { + this.non$clampGatherFuse(); + ci.cancel(); + } + } + } + + @Unique + private boolean non$shouldSuppressFuse() { + return this.non$isAnimationControlled() || this.non$hasIgnoredAnimationActorNearby(); + } + + @Unique + private boolean non$isAnimationControlled() { + CreeperEntity self = (CreeperEntity)(Object)this; + return self.getEntityWorld() instanceof ServerWorld world ? AnimationService.isActorPendingOrActive(world, self.getUuid()) : false; + } + + @Unique + private boolean non$hasIgnoredAnimationActorNearby() { + CreeperEntity self = (CreeperEntity)(Object)this; + if (self.getEntityWorld() instanceof ServerWorld world) { + LivingEntity target = self.getTarget(); + if (target != null && ServerAnimationController.shouldIgnoreAttackers(world, target)) { + self.setTarget(null); + self.getNavigation().stop(); + return true; + } else { + Box searchBox = self.getBoundingBox().expand(7.0); + return !world.getEntitiesByClass( + LivingEntity.class, + searchBox, + actor -> actor != self + && actor.isAlive() + && !actor.isRemoved() + && self.squaredDistanceTo(actor) <= 49.0 + && ServerAnimationController.shouldIgnoreAttackers(world, actor) + ) + .isEmpty(); + } + } else { + return false; + } + } + + @Unique + private boolean non$isNoNGathering() { + CreeperEntity self = (CreeperEntity)(Object)this; + return self.getEntityWorld() instanceof ServerWorld world ? NeedsOfNature.isNoNGatheringActor(world, self.getUuid()) : false; + } + + @Unique + private void non$resetFuse() { + CreeperEntity self = (CreeperEntity)(Object)this; + this.currentFuseTime = 0; + this.lastFuseTime = 0; + if (FUSE_SPEED != null) { + self.getDataTracker().set(FUSE_SPEED, 0); + } + + if (IGNITED != null) { + self.getDataTracker().set(IGNITED, false); + } + } + + @Unique + private void non$clampGatherFuse() { + int maxFuse = Math.max(1, this.fuseTime); + int safeFuse = Math.max(0, maxFuse - 1); + this.currentFuseTime = Math.min(Math.max(0, this.currentFuseTime), safeFuse); + this.lastFuseTime = Math.min(Math.max(0, this.lastFuseTime), this.currentFuseTime); + } +} diff --git a/src/main/java/com/nonid/mixin/EntityEnergyPersistenceMixin.java b/src/main/java/com/nonid/mixin/EntityEnergyPersistenceMixin.java new file mode 100644 index 0000000..d42410a --- /dev/null +++ b/src/main/java/com/nonid/mixin/EntityEnergyPersistenceMixin.java @@ -0,0 +1,52 @@ +package com.nonid.mixin; + +import com.mojang.serialization.Codec; +import com.nonid.EnergyHolder; +import java.util.Optional; +import net.minecraft.entity.Entity; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class EntityEnergyPersistenceMixin { + @Inject(method = "writeData", at = @At("TAIL")) + private void needsOfNature$writeEnergy(WriteView storage, CallbackInfo ci) { + if (this instanceof EnergyHolder holder) { + storage.putInt("NeedsOfNatureEnergy", holder.getEnergy()); + storage.putFloat("NeedsOfNatureEnergyGainMult", holder.getEnergyGainMultiplier()); + storage.putFloat("NeedsOfNatureEnergyGainDrift", holder.getEnergyGainDrift()); + } + } + + @Inject(method = "readData", at = @At("TAIL")) + private void needsOfNature$readEnergy(ReadView storage, CallbackInfo ci) { + if (this instanceof EnergyHolder holder) { + boolean loaded = false; + Optional energyOpt = storage.getOptionalInt("NeedsOfNatureEnergy"); + if (energyOpt.isPresent()) { + holder.setEnergy(energyOpt.get()); + loaded = true; + } + + Optional multOpt = storage.read("NeedsOfNatureEnergyGainMult", Codec.FLOAT); + if (multOpt.isPresent()) { + holder.setEnergyGainMultiplier(multOpt.get()); + loaded = true; + } + + Optional driftOpt = storage.read("NeedsOfNatureEnergyGainDrift", Codec.FLOAT); + if (driftOpt.isPresent()) { + holder.setEnergyGainDrift(driftOpt.get()); + loaded = true; + } + + if (loaded) { + holder.markEnergyInitialized(); + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/EntityGenderPersistenceMixin.java b/src/main/java/com/nonid/mixin/EntityGenderPersistenceMixin.java new file mode 100644 index 0000000..2ada73b --- /dev/null +++ b/src/main/java/com/nonid/mixin/EntityGenderPersistenceMixin.java @@ -0,0 +1,51 @@ +package com.nonid.mixin; + +import com.nonid.DestroyedSkinHolder; +import com.nonid.GenderHolder; +import com.nonid.MessHolder; +import net.minecraft.entity.Entity; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class EntityGenderPersistenceMixin { + @Inject(method = "writeData", at = @At("TAIL")) + private void needsOfNature$writeGender(WriteView storage, CallbackInfo ci) { + if (this instanceof GenderHolder holder) { + storage.putInt("NeedsOfNatureGender", holder.getGenderMask()); + } + + if (this instanceof DestroyedSkinHolder holder) { + storage.putInt("NeedsOfNatureDestroyedSkinStage", holder.getDestroyedSkinStage()); + storage.putInt("NeedsOfNatureDestroyedSkinDamage", holder.getDestroyedSkinDamage()); + } + + if (this instanceof MessHolder holder) { + storage.putInt("NeedsOfNatureMessV", holder.getVMess()); + storage.putInt("NeedsOfNatureMessA", holder.getAMess()); + storage.putInt("NeedsOfNatureMessM", holder.getMMess()); + } + } + + @Inject(method = "readData", at = @At("TAIL")) + private void needsOfNature$readGender(ReadView storage, CallbackInfo ci) { + if (this instanceof GenderHolder holder) { + storage.getOptionalInt("NeedsOfNatureGender").ifPresent(holder::setGenderMask); + } + + if (this instanceof DestroyedSkinHolder holder) { + storage.getOptionalInt("NeedsOfNatureDestroyedSkinStage").ifPresent(holder::setDestroyedSkinStage); + storage.getOptionalInt("NeedsOfNatureDestroyedSkinDamage").ifPresent(holder::setDestroyedSkinDamage); + } + + if (this instanceof MessHolder holder) { + storage.getOptionalInt("NeedsOfNatureMessV").ifPresent(holder::setVMess); + storage.getOptionalInt("NeedsOfNatureMessA").ifPresent(holder::setAMess); + storage.getOptionalInt("NeedsOfNatureMessM").ifPresent(holder::setMMess); + } + } +} diff --git a/src/main/java/com/nonid/mixin/FileResourcePackProviderAccessor.java b/src/main/java/com/nonid/mixin/FileResourcePackProviderAccessor.java new file mode 100644 index 0000000..1428d9d --- /dev/null +++ b/src/main/java/com/nonid/mixin/FileResourcePackProviderAccessor.java @@ -0,0 +1,16 @@ +package com.nonid.mixin; + +import net.minecraft.resource.FileResourcePackProvider; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.path.SymlinkFinder; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(FileResourcePackProvider.class) +public interface FileResourcePackProviderAccessor { + @Accessor("type") + ResourceType non$getType(); + + @Accessor("symlinkFinder") + SymlinkFinder non$getSymlinkFinder(); +} diff --git a/src/main/java/com/nonid/mixin/FleeEntityGoalMixin.java b/src/main/java/com/nonid/mixin/FleeEntityGoalMixin.java new file mode 100644 index 0000000..e08ebd0 --- /dev/null +++ b/src/main/java/com/nonid/mixin/FleeEntityGoalMixin.java @@ -0,0 +1,45 @@ +package com.nonid.mixin; + +import com.nonid.NonPlayerAvoidanceState; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.ai.goal.FleeEntityGoal; +import net.minecraft.entity.mob.PathAwareEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(FleeEntityGoal.class) +public abstract class FleeEntityGoalMixin { + @Shadow + @Final + protected PathAwareEntity mob; + @Shadow + protected LivingEntity targetEntity; + + @Inject(method = "canStart", at = @At("RETURN"), cancellable = true) + private void non$suppressPlayerAvoidanceStart(CallbackInfoReturnable cir) { + if (cir.getReturnValueZ() && this.non$shouldSuppressPlayerAvoidance()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "shouldContinue", at = @At("RETURN"), cancellable = true) + private void non$suppressPlayerAvoidanceContinuation(CallbackInfoReturnable cir) { + if (cir.getReturnValueZ() && this.non$shouldSuppressPlayerAvoidance()) { + this.mob.getNavigation().stop(); + cir.setReturnValue(false); + } + } + + @Unique + private boolean non$shouldSuppressPlayerAvoidance() { + return this.targetEntity instanceof ServerPlayerEntity player + && this.mob instanceof NonPlayerAvoidanceState state + && state.shouldSuppressPlayerAvoidance(player); + } +} diff --git a/src/main/java/com/nonid/mixin/LivingEntityLiquidBottleConsumeMixin.java b/src/main/java/com/nonid/mixin/LivingEntityLiquidBottleConsumeMixin.java new file mode 100644 index 0000000..34dadf9 --- /dev/null +++ b/src/main/java/com/nonid/mixin/LivingEntityLiquidBottleConsumeMixin.java @@ -0,0 +1,35 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import com.nonid.potion.NonPotions; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.server.network.ServerPlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityLiquidBottleConsumeMixin { + @Inject(method = "consumeItem", at = @At("HEAD")) + private void non$grantEmergencySnackForEntityLiquidBottle(CallbackInfo ci) { + if ((Object)this instanceof ServerPlayerEntity player) { + ItemStack stack = player.getActiveItem(); + boolean entityLiquidBottle = NonPotions.isLiquidBottle(stack) && NonPotions.getLiquidBottleEntityTypeId(stack) != null; + if (entityLiquidBottle) { + NeedsOfNature.grantEmergencySnackAdvancement(player); + } else { + if (stack.isOf(Items.MILK_BUCKET)) { + NeedsOfNature.grantUdderlyUnfortunateAdvancementIfPregnant(player); + } + + if (stack.contains(DataComponentTypes.FOOD) || stack.contains(DataComponentTypes.CONSUMABLE)) { + NeedsOfNature.resetStableDietStreak(player); + } + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/LivingEntityLiquidJumpMixin.java b/src/main/java/com/nonid/mixin/LivingEntityLiquidJumpMixin.java new file mode 100644 index 0000000..bc17288 --- /dev/null +++ b/src/main/java/com/nonid/mixin/LivingEntityLiquidJumpMixin.java @@ -0,0 +1,57 @@ +package com.nonid.mixin; + +import com.nonid.LiquidHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonTrinketsIntegration; +import com.nonid.effect.NonStatusEffects; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.registry.Registries; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityLiquidJumpMixin { + @Inject(method = "jump", at = @At("TAIL")) + private void needsOfNature$applyLiquidJumpSlowdown(CallbackInfo ci) { + LivingEntity self = (LivingEntity)(Object)this; + if (self instanceof PlayerEntity) { + if (self instanceof LiquidHolder) { + float multiplier = 1.0F; + StatusEffectInstance effect = self.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FILLED)); + if (effect != null) { + int amplifier = effect.getAmplifier(); + float liquidMultiplier = amplifier >= 2 + ? NeedsOfNature.getConfig().getFilledStageThreeJumpMult() + : (amplifier >= 1 ? NeedsOfNature.getConfig().getFilledStageTwoJumpMult() : NeedsOfNature.getConfig().getFilledStageOneJumpMult()); + liquidMultiplier = non$applyFilledAccessoryMultiplier(self, liquidMultiplier); + multiplier *= liquidMultiplier; + } + + StatusEffectInstance pregnancy = self.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT)); + if (pregnancy != null) { + multiplier *= NeedsOfNature.getPregnancyMovementMultiplier(pregnancy.getDuration()); + } + + if (!(Math.abs(multiplier - 1.0F) < 1.0E-4F)) { + Vec3d velocity = self.getVelocity(); + self.setVelocity(velocity.x * multiplier, velocity.y * multiplier, velocity.z * multiplier); + } + } + } + } + + private static float non$applyFilledAccessoryMultiplier(LivingEntity entity, float multiplier) { + double effectMultiplier = NonTrinketsIntegration.getActiveTankAccessoryEffects(entity).filledEffectMultiplier(); + if (!Double.isFinite(effectMultiplier)) { + return multiplier; + } + + double penalty = 1.0 - multiplier; + return (float)Math.max(0.0, Math.min(2.0, 1.0 - penalty * effectMultiplier)); + } +} diff --git a/src/main/java/com/nonid/mixin/MobEntityEnergyMixin.java b/src/main/java/com/nonid/mixin/MobEntityEnergyMixin.java new file mode 100644 index 0000000..89e8832 --- /dev/null +++ b/src/main/java/com/nonid/mixin/MobEntityEnergyMixin.java @@ -0,0 +1,1003 @@ +package com.nonid.mixin; + +import com.nonid.EnergyHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonAnimationMatcher; +import com.nonid.NonDebugChatCategory; +import com.nonid.NonGatherSystem; +import com.nonid.NonMobEnergySystem; +import com.nonid.NonMobGatherEligibility; +import com.nonid.NonPlayerAvoidanceState; +import com.nonid.data.NonEntityProfiles; +import com.nonid.internal.animation.api.AnimationService; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.network.AnimationStageInfo; +import com.nonid.particle.NonParticles; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityData; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.SpawnReason; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.data.DataTracker.Builder; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.registry.Registries; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Box; +import net.minecraft.world.LocalDifficulty; +import net.minecraft.world.ServerWorldAccess; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(MobEntity.class) +public abstract class MobEntityEnergyMixin extends LivingEntity implements EnergyHolder, NonPlayerAvoidanceState { + @Unique + private static final int PLAYER_AVOIDANCE_ELIGIBILITY_CACHE_TICKS = 20; + @Unique + private static final int LINGER_MATCH_CACHE_TICKS = 10; + @Unique + private static final TrackedData ENERGY = DataTracker.registerData(MobEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private int non$energyTickCounter; + @Unique + private boolean non$energyInitialized; + @Unique + private float non$energyGainMult = -1.0F; + @Unique + private float non$energyGainDrift = -1.0F; + @Unique + private double non$energyAuraCarry; + @Unique + private int non$nearAnimCheckCooldown; + @Unique + private boolean non$nearActiveAnimation; + @Unique + private double non$nearActiveAnimationGainMultiplier = 1.0; + @Unique + @Nullable + private UUID non$lingerLookTargetUuid; + @Unique + private int non$lingerMatchCooldown; + @Unique + private int non$heartParticleCooldown; + @Unique + private int non$playerSearchCooldown; + @Unique + private int non$mobSearchCooldown; + @Unique + private long non$lastEnergyProcessedTick = Long.MIN_VALUE; + @Unique + private boolean non$attackFailsafeWarned; + @Unique + private final Map non$partnerCooldowns = new HashMap<>(); + @Unique + private final Map non$bypassablePartnerCooldowns = new HashMap<>(); + @Unique + private final Map non$multiActorJoinCooldowns = new HashMap<>(); + @Unique + @Nullable + private UUID non$avoidancePlayerUuid; + @Unique + @Nullable + private UUID non$avoidanceInstanceUuid; + @Unique + private long non$avoidanceEligibilityExpiresTick = Long.MIN_VALUE; + @Unique + private boolean non$avoidanceEligible; + + protected MobEntityEnergyMixin(EntityType entityType, World world) { + super(entityType, world); + } + + @Inject(method = "initDataTracker", at = @At("TAIL")) + private void needsOfNature$initEnergyTracker(Builder builder, CallbackInfo ci) { + builder.add(ENERGY, 0); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void needsOfNature$tickEnergy(CallbackInfo ci) { + if (this.getEntityWorld() instanceof ServerWorld serverWorld) { + long worldTick = serverWorld.getTime(); + if (this.non$lastEnergyProcessedTick != worldTick) { + this.non$lastEnergyProcessedTick = worldTick; + this.non$ensureEnergyInitialized(serverWorld); + boolean non$hasAnimationSupport = NonMobEnergySystem.hasGenericAnimationSupport(this.getType(), this.isBaby()); + boolean attackFailsafeActive = NeedsOfNature.isMobAttackFailsafeActive(serverWorld, this.getUuid()); + if (attackFailsafeActive) { + this.non$lingerLookTargetUuid = null; + if (!this.non$attackFailsafeWarned) { + NeedsOfNature.sendDebugChatToNearby( + serverWorld, + this, + 20.0, + NonDebugChatCategory.WARNING, + Text.translatable("debug.needsofnature.energy_locked_attack_cooldown", new Object[]{Text.translatable(this.getType().getTranslationKey())}) + ); + this.non$attackFailsafeWarned = true; + } + + if (this.getEnergy() != 0) { + this.setEnergy(0); + } + + this.non$energyTickCounter = 0; + this.non$heartParticleCooldown = 0; + this.non$playerSearchCooldown = 40; + this.non$mobSearchCooldown = 40; + } else { + this.non$attackFailsafeWarned = false; + if (!non$hasAnimationSupport) { + this.non$lingerLookTargetUuid = null; + this.non$nearActiveAnimation = false; + this.non$nearActiveAnimationGainMultiplier = 1.0; + if (this.getEnergy() >= 200) { + this.setEnergy(199); + } + + this.non$energyTickCounter = 0; + this.non$heartParticleCooldown = 0; + } else if (this.non$nearAnimCheckCooldown-- <= 0) { + this.non$nearActiveAnimationGainMultiplier = NeedsOfNature.getNearbyVisibleAnimationGainMultiplier(serverWorld, (MobEntity)(Object)this); + this.non$nearActiveAnimation = this.non$nearActiveAnimationGainMultiplier > 1.0; + this.non$nearAnimCheckCooldown = 20; + } + + if (non$hasAnimationSupport) { + int energy = this.getEnergy(); + if (energy >= 200 && NonMobEnergySystem.isEnergyAttackStabilized(this)) { + this.setEnergy(199); + energy = this.getEnergy(); + } + + int ticksPerGain = NonMobEnergySystem.ticksPerGain( + (MobEntity)(Object)this, + this.getEnergyGainMultiplier(), + this.getEnergyGainDrift(), + this.non$nearActiveAnimation, + this.non$nearActiveAnimationGainMultiplier + ); + if (energy < 200) { + this.non$energyTickCounter++; + if (this.non$energyTickCounter >= ticksPerGain) { + this.non$energyTickCounter = 0; + this.setEnergy(Math.min(200, energy + 1)); + } + + this.non$heartParticleCooldown = 0; + } else { + this.non$energyTickCounter = 0; + this.non$spawnMaxEnergyHeartParticle(serverWorld); + } + + this.non$maybeStartEnergyAnimation(serverWorld, energy); + } + } + } + } + } + + @Inject(method = "tickNewAi", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/ai/control/LookControl;tick()V")) + private void needsOfNature$applyLingerLookTarget(CallbackInfo ci) { + UUID targetUuid = this.non$lingerLookTargetUuid; + if (targetUuid != null) { + if (this.getEntityWorld() instanceof ServerWorld world) { + MobEntity self = (MobEntity)(Object)this; + if (NonEntityProfiles.resolve(self).lookAtNearbyAnimations()) { + if (world.getEntity(targetUuid) instanceof ServerPlayerEntity player + && player.isAlive() + && !player.isRemoved() + && !player.isSpectator() + && !player.isCreative() + && NeedsOfNature.getActivePlayerInstance(player) != null + && !NeedsOfNature.isActorPendingOrActive(world, self.getUuid())) { + double lookY = Math.max(player.getY() + 0.6, player.getEyeY() - 2.0); + self.getLookControl().lookAt(player.getX(), lookY, player.getZ(), 30.0F, 30.0F); + } else { + this.non$lingerLookTargetUuid = null; + } + } + } else { + this.non$lingerLookTargetUuid = null; + } + } + } + + @Inject(method = "initialize", at = @At("TAIL")) + private void needsOfNature$initEnergyOnSpawn( + ServerWorldAccess world, LocalDifficulty difficulty, SpawnReason spawnReason, @Nullable EntityData entityData, CallbackInfoReturnable cir + ) { + if (world instanceof ServerWorld serverWorld) { + this.non$ensureEnergyInitialized(serverWorld); + } + } + + @Override + public int getEnergy() { + return (Integer)this.dataTracker.get(ENERGY); + } + + @Override + public void setEnergy(int value) { + int maxEnergy = NonMobEnergySystem.isEnergyAttackStabilized(this) ? 199 : 200; + this.dataTracker.set(ENERGY, Math.min(maxEnergy, Math.max(0, value))); + } + + @Override + public float getEnergyGainMultiplier() { + return this.non$energyGainMult <= 0.0F ? 1.0F : this.non$energyGainMult; + } + + @Override + public void setEnergyGainMultiplier(float value) { + if (Float.isFinite(value)) { + this.non$energyGainMult = Math.max(0.4F, Math.min(6.0F, value)); + } + } + + @Override + public float getEnergyGainDrift() { + return this.non$energyGainDrift <= 0.0F ? 1.0F : this.non$energyGainDrift; + } + + @Override + public void setEnergyGainDrift(float value) { + if (Float.isFinite(value)) { + this.non$energyGainDrift = Math.max(0.8F, Math.min(1.2F, value)); + } + } + + @Override + public double getEnergyAuraCarry() { + return this.non$energyAuraCarry > 0.0 ? this.non$energyAuraCarry : 0.0; + } + + @Override + public void setEnergyAuraCarry(double value) { + if (!Double.isFinite(value)) { + this.non$energyAuraCarry = 0.0; + } else { + this.non$energyAuraCarry = Math.max(0.0, value); + } + } + + @Override + public boolean isEnergyInitialized() { + return this.non$energyInitialized; + } + + @Override + public void markEnergyInitialized() { + this.non$energyInitialized = true; + } + + @Override + public void ensureEnergyInitialized(ServerWorld world) { + if (world != null) { + this.non$ensureEnergyInitialized(world); + } + } + + @Override + public int getMaxEnergy() { + return 200; + } + + @Override + public void onNonAnimationStarted() { + this.non$partnerCooldowns.clear(); + this.non$bypassablePartnerCooldowns.clear(); + this.non$multiActorJoinCooldowns.clear(); + } + + @Override + public void applyPartnerCooldown(UUID partnerId, long nowTick, int cooldownTicks) { + if (partnerId != null) { + if (cooldownTicks > 0) { + long until = nowTick + cooldownTicks; + this.non$partnerCooldowns.put(partnerId, until); + this.non$bypassablePartnerCooldowns.put(partnerId, until); + } + } + } + + @Override + public List getActiveCooldownDebugLines(ServerWorld world) { + List lines = new ArrayList<>(); + if (world == null) { + return lines; + } + + long nowTick = world.getTime(); + this.non$pruneCooldownMaps(nowTick); + if (this.non$playerSearchCooldown > 0) { + lines.add("player search delay: " + NeedsOfNature.formatTicksForDebug(this.non$playerSearchCooldown)); + } + + if (this.non$mobSearchCooldown > 0) { + lines.add("mob search delay: " + NeedsOfNature.formatTicksForDebug(this.non$mobSearchCooldown)); + } + + NonMobEnergySystem.appendCooldownMapDebug(lines, world, nowTick, "partner", this.non$partnerCooldowns, this.non$bypassablePartnerCooldowns, true); + NonMobEnergySystem.appendCooldownMapDebug(lines, world, nowTick, "3+ join", this.non$multiActorJoinCooldowns, this.non$bypassablePartnerCooldowns, false); + return lines; + } + + @Override + public void requestAttackOnPlayer(ServerWorld world, ServerPlayerEntity player) { + if (world != null && player != null) { + if (this.getEntityWorld() == world) { + if (!NonMobEnergySystem.isEnergyAttackStabilized(this) || this.getEnergy() < 200) { + if (NonMobEnergySystem.hasGenericAnimationSupport(this.getType(), this.isBaby())) { + if (NonMobGatherEligibility.canInitiateAutoGather((MobEntity)(Object)this)) { + if (NeedsOfNature.canMobAttackPlayer(player)) { + if (!NeedsOfNature.isActorPendingOrActive(world, this.getUuid())) { + if (!NeedsOfNature.isMobAttackFailsafeActive(world, this.getUuid())) { + this.non$playerSearchCooldown = 0; + this.non$partnerCooldowns.remove(player.getUuid()); + this.non$bypassablePartnerCooldowns.remove(player.getUuid()); + this.non$multiActorJoinCooldowns.remove(player.getUuid()); + this.non$tryPlayerInteraction(world, this.getEnergy(), true, true, player); + } + } + } + } + } + } + } + } + } + + @Override + public boolean shouldSuppressPlayerAvoidance(ServerPlayerEntity player) { + if (this.getEntityWorld() instanceof ServerWorld world && player != null) { + MobEntity self = (MobEntity)(Object)this; + if (player.getEntityWorld() != world || !player.isAlive() || player.isRemoved() || player.isSpectator()) { + return false; + } + + if (NonGatherSystem.isMobGatheringWithPlayer(world, self.getUuid(), player.getUuid())) { + return true; + } + + if (this.getEnergy() >= 200 + && NonMobGatherEligibility.canInitiateAutoGather(self) + && !NeedsOfNature.isActorPendingOrActive(world, self.getUuid()) + && !NeedsOfNature.isMobAttackFailsafeActive(world, self.getUuid())) { + UUID activeInstance = NeedsOfNature.getActivePlayerInstance(player); + boolean interactionIsAttack = activeInstance == null || NeedsOfNature.isInstanceAttack(world, activeInstance); + if (interactionIsAttack && !NeedsOfNature.canMobAttackPlayer(player)) { + return false; + } + + if (activeInstance == null && NonGatherSystem.isAutomaticMobPlayerStartBlockedByMount(player)) { + return false; + } + + long nowTick = world.getTime(); + if (player.getUuid().equals(this.non$avoidancePlayerUuid) + && Objects.equals(activeInstance, this.non$avoidanceInstanceUuid) + && nowTick < this.non$avoidanceEligibilityExpiresTick) { + return this.non$avoidanceEligible; + } + + this.non$avoidancePlayerUuid = player.getUuid(); + this.non$avoidanceInstanceUuid = activeInstance; + this.non$avoidanceEligibilityExpiresTick = nowTick + 20L; + this.non$avoidanceEligible = this.non$hasEligiblePlayerInteraction(world, self, player, activeInstance); + return this.non$avoidanceEligible; + } else { + return false; + } + } else { + return false; + } + } + + @Unique + private boolean non$hasEligiblePlayerInteraction(ServerWorld world, MobEntity self, ServerPlayerEntity player, @Nullable UUID activeInstance) { + List actors; + Entity requiredActiveActor; + Identifier predecessorAnimationId; + boolean attackSelection; + if (activeInstance != null) { + AnimationStageInfo stage = AnimationService.getCurrentStage(world, activeInstance); + if (stage != null && !stage.allowJoin()) { + return false; + } + + predecessorAnimationId = AnimationService.getActiveAnimationId(world, activeInstance); + if (predecessorAnimationId == null) { + return false; + } + + actors = this.non$resolveJoinActors(world, activeInstance); + attackSelection = NeedsOfNature.isInstanceAttack(world, activeInstance); + requiredActiveActor = attackSelection ? self : null; + } else { + if (AnimationService.isActorPendingOrActive(world, player.getUuid())) { + return false; + } + + actors = new ArrayList<>(2); + actors.add(self); + actors.add(player); + actors.sort(Comparator.comparingInt(Entity::getId)); + requiredActiveActor = self; + predecessorAnimationId = null; + attackSelection = true; + } + + List eligible = attackSelection + ? NonAnimationMatcher.findEligibleAutomaticAttack(world, actors, Set.of(), player, true, requiredActiveActor, Set.of(), predecessorAnimationId) + : NonAnimationMatcher.findEligibleAutomatic(world, actors, Set.of(), player, true, requiredActiveActor, Set.of(), predecessorAnimationId); + return !eligible.isEmpty(); + } + + @Unique + private void non$maybeStartEnergyAnimation(ServerWorld world, int currentEnergy) { + if (!NonMobEnergySystem.hasGenericAnimationSupport(this.getType(), this.isBaby())) { + this.non$lingerLookTargetUuid = null; + } else { + MobEntity self = (MobEntity)(Object)this; + if (!NonMobGatherEligibility.canInitiateAutoGather(self)) { + this.non$lingerLookTargetUuid = null; + } else { + long nowTick = world.getTime(); + this.non$pruneCooldownMaps(nowTick); + if (NeedsOfNature.isActorPendingOrActive(world, this.getUuid())) { + this.non$lingerLookTargetUuid = null; + } else { + boolean canJoin = currentEnergy >= 70; + boolean canMobMatch = currentEnergy >= 120 && currentEnergy < 200; + boolean isAttack = currentEnergy >= 200; + if (!canJoin) { + this.non$lingerLookTargetUuid = null; + } else { + ServerPlayerEntity lingerPlayer = null; + lingerPlayer = this.non$findNearestActiveAnimationPlayerForLinger(world); + if (lingerPlayer != null) { + this.non$applyPlayerAnimationLinger(lingerPlayer); + } else { + this.non$lingerLookTargetUuid = null; + } + + if (this.non$playerSearchCooldown > 0) { + this.non$playerSearchCooldown--; + } else { + this.non$playerSearchCooldown = 40; + if (NeedsOfNature.consumeScanBudget(world) && this.non$tryPlayerInteraction(world, currentEnergy, true, isAttack, null)) { + this.non$playerSearchCooldown = 100; + return; + } + } + + if (this.non$mobSearchCooldown > 0) { + this.non$mobSearchCooldown--; + } else { + this.non$mobSearchCooldown = 40; + if (NeedsOfNature.consumeScanBudget(world)) { + if (lingerPlayer == null) { + if (canMobMatch || isAttack) { + Identifier selfTypeId = Registries.ENTITY_TYPE.getId(this.getType()); + boolean selfIsBaby = this.isBaby(); + if (AnimationDefinitions.hasEnabledGenericMobPairAnimationForEntityType(selfTypeId, selfIsBaby)) { + Box searchBox = this.getBoundingBox().expand(20.0); + List candidates = world.getEntitiesByClass( + MobEntity.class, + searchBox, + mob -> !mob.getUuid().equals(this.getUuid()) + && NonMobGatherEligibility.canBeAutoGatherTarget(mob) + && mob.isAlive() + && !mob.isRemoved() + && mob.getEntityWorld() == world + && AnimationDefinitions.areEnabledGenericMobPairTypesCompatible( + selfTypeId, selfIsBaby, Registries.ENTITY_TYPE.getId(mob.getType()), mob.isBaby() + ) + ); + if (!candidates.isEmpty()) { + candidates.sort(Comparator.comparing(candidatex -> !NonMobEnergySystem.bypassesEntityEntityRoll(this, candidatex))); + boolean entityEntityRollChecked = false; + boolean entityEntityRollPassed = false; + + for (MobEntity candidate : candidates) { + boolean bypassEntityEntityRoll = NonMobEnergySystem.bypassesEntityEntityRoll(this, candidate); + if (!bypassEntityEntityRoll && !entityEntityRollChecked) { + entityEntityRollChecked = true; + entityEntityRollPassed = NonMobEnergySystem.passesEntityEntityRoll(world, this); + if (!entityEntityRollPassed) { + this.non$mobSearchCooldown = 1200; + return; + } + } + + if (candidate instanceof EnergyHolder holder && !NeedsOfNature.isActorPendingOrActive(world, candidate.getUuid())) { + int candidateEnergy = holder.getEnergy(); + if (isAttack || candidateEnergy >= 70) { + List actors = new ArrayList<>(2); + actors.add(this); + actors.add(candidate); + actors.sort(Comparator.comparingInt(Entity::getId)); + if (!this.non$partnerCooldowns.containsKey(candidate.getUuid())) { + NonGatherSystem.GatherCandidate gatherCandidate = NonGatherSystem.findGatherCandidate( + world, actors, isAttack, (Entity)this, null + ); + if (gatherCandidate != null) { + if (!NonMobEnergySystem.passesSameGenderRoll(world, this, actors)) { + this.non$applyRollCooldown(candidate.getUuid(), nowTick); + this.non$mobSearchCooldown = 100; + } else { + Text mobName = this.getDisplayName(); + Text partnerName = candidate.getDisplayName(); + NeedsOfNature.sendDebugChatToNearby( + world, + this, + 20.0, + NonDebugChatCategory.INFO, + Text.translatable( + "debug.needsofnature.mob_pair_attempt", + new Object[]{ + mobName, partnerName, currentEnergy, candidateEnergy, gatherCandidate.animationId().toString() + } + ) + ); + NonGatherSystem.startMobMobGather( + world, + (MobEntity)(Object)this, + candidate, + gatherCandidate.animationId(), + gatherCandidate.actorKeys(), + gatherCandidate.stages(), + isAttack + ); + NeedsOfNature.sendDebugChatToNearby( + world, + this, + 20.0, + NonDebugChatCategory.INFO, + Text.translatable( + "debug.needsofnature.mob_pair_gathering", + new Object[]{mobName, partnerName, gatherCandidate.animationId().toString()} + ) + ); + this.non$mobSearchCooldown = 100; + } + break; + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + @Unique + private boolean non$tryPlayerInteraction(ServerWorld world, int currentEnergy, boolean canJoin, boolean isAttack, @Nullable ServerPlayerEntity forcedPlayer) { + MobEntity self = (MobEntity)(Object)this; + if (!NonMobGatherEligibility.canInitiateAutoGather(self)) { + return false; + } + + long nowTick = world.getTime(); + double maxDistSq = 400.0; + Text mobName = this.getDisplayName(); + String mobTypeId = Registries.ENTITY_TYPE.getId(this.getType()).toString(); + boolean attackFailsafeActive = isAttack && NeedsOfNature.isMobAttackFailsafeActive(world, this.getUuid()); + if (forcedPlayer == null && world.getPlayers().isEmpty()) { + return false; + } + + if (attackFailsafeActive) { + return false; + } + + int mobChunkX = (int)Math.floor(this.getX()) >> 4; + int mobChunkZ = (int)Math.floor(this.getZ()) >> 4; + boolean playerNearbyChunk = false; + if (forcedPlayer == null) { + for (ServerPlayerEntity player : world.getPlayers()) { + if (player.isAlive() && !player.isRemoved()) { + UUID inst = NeedsOfNature.getActivePlayerInstance(player); + boolean interactionIsAttack = inst == null ? isAttack : NeedsOfNature.isInstanceAttack(world, inst); + if (!interactionIsAttack || NeedsOfNature.canMobAttackPlayer(player)) { + double distSq = player.squaredDistanceTo(this); + boolean bypassCooldown = this.non$shouldBypassPlayerCooldown(player, distSq); + if (!this.non$partnerCooldowns.containsKey(player.getUuid()) || bypassCooldown) { + if (bypassCooldown) { + this.non$partnerCooldowns.remove(player.getUuid()); + this.non$bypassablePartnerCooldowns.remove(player.getUuid()); + } + + int px = (int)Math.floor(player.getX()) >> 4; + int pz = (int)Math.floor(player.getZ()) >> 4; + if (Math.abs(px - mobChunkX) <= 1 && Math.abs(pz - mobChunkZ) <= 1) { + playerNearbyChunk = true; + break; + } + } + } + } + } + + if (!playerNearbyChunk) { + return false; + } + } + + ServerPlayerEntity closest = null; + double closestDistSq = Double.MAX_VALUE; + if (forcedPlayer != null) { + if (!forcedPlayer.isAlive() || forcedPlayer.isRemoved() || forcedPlayer.getEntityWorld() != world) { + return false; + } + + UUID inst = NeedsOfNature.getActivePlayerInstance(forcedPlayer); + boolean interactionIsAttack = inst == null ? isAttack : NeedsOfNature.isInstanceAttack(world, inst); + if (interactionIsAttack && !NeedsOfNature.canMobAttackPlayer(forcedPlayer)) { + return false; + } + + double distSq = forcedPlayer.squaredDistanceTo(this); + if (distSq > maxDistSq) { + return false; + } + + closest = forcedPlayer; + closestDistSq = distSq; + } else { + for (ServerPlayerEntity player : world.getPlayers()) { + if (player.isAlive() && !player.isRemoved()) { + UUID inst = NeedsOfNature.getActivePlayerInstance(player); + boolean interactionIsAttack = inst == null ? isAttack : NeedsOfNature.isInstanceAttack(world, inst); + if (!interactionIsAttack || NeedsOfNature.canMobAttackPlayer(player)) { + double distSq = player.squaredDistanceTo(this); + boolean bypassCooldown = this.non$shouldBypassPlayerCooldown(player, distSq); + if (!this.non$partnerCooldowns.containsKey(player.getUuid()) || bypassCooldown) { + if (bypassCooldown) { + this.non$partnerCooldowns.remove(player.getUuid()); + this.non$bypassablePartnerCooldowns.remove(player.getUuid()); + } + + if (!(distSq > maxDistSq) && (inst != null || isAttack) && distSq < closestDistSq) { + closestDistSq = distSq; + closest = player; + } + } + } + } + } + } + + if (closest == null) { + return false; + } + + UUID activeInstance = NeedsOfNature.getActivePlayerInstance(closest); + AnimationStageInfo activeStage = activeInstance == null ? null : AnimationService.getCurrentStage(world, activeInstance); + if (activeInstance != null) { + boolean activeIsAttack = NeedsOfNature.isInstanceAttack(world, activeInstance); + boolean joinAsAttack = isAttack && activeIsAttack; + if (activeStage != null && !activeStage.allowJoin()) { + return false; + } else { + Identifier predecessorAnimationId = AnimationService.getActiveAnimationId(world, activeInstance); + if (predecessorAnimationId == null) { + return true; + } else { + List combinedActors = this.non$resolveJoinActors(world, activeInstance); + AnimationService.MatchedAnimation combinedMatch = activeIsAttack + ? NonAnimationMatcher.findAutomaticAttack( + world, combinedActors, Set.of(), closest, true, joinAsAttack ? (Entity)this : null, Set.of(), null, predecessorAnimationId + ) + : NonAnimationMatcher.findAutomatic( + world, combinedActors, Set.of(), closest, true, joinAsAttack ? (Entity)this : null, Set.of(), null, predecessorAnimationId + ); + if (combinedMatch == null) { + NeedsOfNature.logRuntime( + "Energy join skipped: mobType={} player={} instance={} no expanded match", mobTypeId, closest.getName().getString(), activeInstance + ); + NeedsOfNature.sendDebugChat( + closest, NonDebugChatCategory.WARNING, Text.translatable("debug.needsofnature.mob_join_no_expanded_match", new Object[]{mobName}) + ); + return true; + } else { + String playerName = closest.getName().getString(); + String joinAnimationId = combinedMatch.animationId().toString(); + NeedsOfNature.logRuntime( + "Energy join attempt: mobType={} energy={} -> player={} instance={} distanceSq={}", + mobTypeId, + currentEnergy, + playerName, + activeInstance, + closestDistSq + ); + NeedsOfNature.sendDebugChat( + closest, + NonDebugChatCategory.INFO, + Text.translatable("debug.needsofnature.mob_wants_join", new Object[]{mobName, currentEnergy, joinAnimationId}) + ); + int multiActorJoinChance = NonMobEnergySystem.resolveMultiActorJoinChancePercent(this); + if (combinedActors.size() > 2 && multiActorJoinChance < 100 && this.non$multiActorJoinCooldowns.containsKey(closest.getUuid())) { + return true; + } else if (!NonMobEnergySystem.passesMultiActorJoinRoll(world, this, combinedActors.size(), multiActorJoinChance)) { + this.non$applyNonBypassRollCooldown(closest.getUuid(), nowTick); + return true; + } else { + NonGatherSystem.startJoinGather(world, (MobEntity)(Object)this, closest, activeInstance, combinedMatch.animationId()); + NeedsOfNature.logRuntime( + "Energy join gathering: mobType={} -> player={} instance={} anim={}", + mobTypeId, + playerName, + activeInstance, + combinedMatch.animationId() + ); + NeedsOfNature.sendDebugChat( + closest, + NonDebugChatCategory.INFO, + Text.translatable("debug.needsofnature.join_request_accepted", new Object[]{mobName, joinAnimationId}) + ); + return true; + } + } + } + } + } else if (isAttack && canJoin) { + List actors = new ArrayList<>(2); + actors.add(this); + actors.add(closest); + actors.sort(Comparator.comparingInt(Entity::getId)); + NonGatherSystem.GatherCandidate gatherCandidate = NonGatherSystem.findGatherCandidate(world, actors, true, (Entity)this, closest); + if (gatherCandidate == null) { + return false; + } else { + String playerName = closest.getName().getString(); + NeedsOfNature.logRuntime( + "Energy start attempt (200): mobType={} -> player={} anim={} distanceSq={}", mobTypeId, playerName, gatherCandidate.animationId(), closestDistSq + ); + NeedsOfNature.sendDebugChat( + closest, NonDebugChatCategory.INFO, Text.translatable("debug.needsofnature.starting_animation_with_mob_200", new Object[]{mobName}) + ); + boolean gatherStarted = NonGatherSystem.startMobPlayerGather( + world, (MobEntity)(Object)this, closest, gatherCandidate.animationId(), gatherCandidate.actorKeys(), gatherCandidate.stages() + ); + if (!gatherStarted) { + this.non$playerSearchCooldown = 100; + return true; + } else { + NeedsOfNature.logRuntime("Energy start gathering: mobType={} player={} anim={}", mobTypeId, playerName, gatherCandidate.animationId()); + NeedsOfNature.sendDebugChat( + closest, NonDebugChatCategory.INFO, Text.translatable("debug.needsofnature.gathering_with_mob", new Object[]{mobName}) + ); + this.non$playerSearchCooldown = 100; + return true; + } + } + } else { + return false; + } + } + + @Unique + private void non$spawnMaxEnergyHeartParticle(ServerWorld world) { + if (world != null) { + this.non$heartParticleCooldown++; + if (this.non$heartParticleCooldown >= 10) { + this.non$heartParticleCooldown = 0; + world.spawnParticles(NonParticles.SMALLHEART, this.getX(), this.getBodyY(0.6), this.getZ(), 1, 0.3, 0.3, 0.3, 0.0); + } + } + } + + @Unique + private boolean non$shouldBypassPlayerCooldown(ServerPlayerEntity player, double distSq) { + if (player == null) { + return false; + } + + if (!this.non$bypassablePartnerCooldowns.containsKey(player.getUuid())) { + return false; + } + + if (distSq > 64.0) { + return false; + } + + MobEntity self = (MobEntity)(Object)this; + return self.canSee(player); + } + + @Unique + @Nullable + private ServerPlayerEntity non$findNearestActiveAnimationPlayerForLinger(ServerWorld world) { + if (world != null && !world.getPlayers().isEmpty()) { + MobEntity self = (MobEntity)(Object)this; + double maxDistSq = 400.0; + ServerPlayerEntity cachedPlayer = null; + if (this.non$lingerLookTargetUuid != null) { + if (world.getEntity(this.non$lingerLookTargetUuid) instanceof ServerPlayerEntity player + && this.non$isBasicPlayerAnimationLingerTarget(player, self, maxDistSq)) { + cachedPlayer = player; + } else { + this.non$lingerLookTargetUuid = null; + } + } + + if (this.non$lingerMatchCooldown > 0) { + this.non$lingerMatchCooldown--; + return cachedPlayer; + } + + this.non$lingerMatchCooldown = 10; + if (!NeedsOfNature.consumeScanBudget(world)) { + return cachedPlayer; + } + + if (cachedPlayer != null && this.non$hasPairAnimationWithPlayer(cachedPlayer)) { + return cachedPlayer; + } + + this.non$lingerLookTargetUuid = null; + ServerPlayerEntity nearest = null; + double nearestDistSq = Double.MAX_VALUE; + + for (ServerPlayerEntity player : world.getPlayers()) { + double distSq = player.squaredDistanceTo(self); + if (!(distSq > maxDistSq) + && !(distSq >= nearestDistSq) + && this.non$isBasicPlayerAnimationLingerTarget(player, self, maxDistSq) + && this.non$hasPairAnimationWithPlayer(player)) { + nearest = player; + nearestDistSq = distSq; + } + } + + return nearest; + } else { + return null; + } + } + + @Unique + private boolean non$isBasicPlayerAnimationLingerTarget(ServerPlayerEntity player, MobEntity self, double maxDistSq) { + if (player == null || self == null || !player.isAlive() || player.isRemoved()) { + return false; + } else if (!player.isSpectator() && !player.isCreative()) { + return player.squaredDistanceTo(self) > maxDistSq ? false : NeedsOfNature.getActivePlayerInstance(player) != null; + } else { + return false; + } + } + + @Unique + private boolean non$hasPairAnimationWithPlayer(ServerPlayerEntity player) { + if (player == null) { + return false; + } else if (player.getEntityWorld() instanceof ServerWorld world) { + List pair = new ArrayList<>(2); + pair.add(this); + pair.add(player); + pair.sort(Comparator.comparingInt(Entity::getId)); + return NonAnimationMatcher.findAutomatic(world, pair, Set.of(), null, false, null, Set.of()) != null; + } else { + return false; + } + } + + @Unique + private void non$applyPlayerAnimationLinger(ServerPlayerEntity player) { + if (player != null) { + MobEntity self = (MobEntity)(Object)this; + this.non$lingerLookTargetUuid = player.getUuid(); + double distSq = self.squaredDistanceTo(player); + if (distSq > 9.0 || distSq < 2.25) { + double targetX = player.getX(); + double targetZ = player.getZ(); + double dx = self.getX() - player.getX(); + double dz = self.getZ() - player.getZ(); + double length = Math.sqrt(dx * dx + dz * dz); + if (length < 1.0E-4) { + double angle = Math.toRadians(Math.floorMod(self.getId() * 47, 360)); + dx = Math.cos(angle); + dz = Math.sin(angle); + length = 1.0; + } + + double ringDistance = 2.25; + targetX += dx / length * ringDistance; + targetZ += dz / length * ringDistance; + self.getNavigation().startMovingTo(targetX, player.getY(), targetZ, 1.1); + } + } + } + + @Unique + private List non$resolveJoinActors(ServerWorld world, UUID instanceId) { + List combined = new ArrayList<>(); + if (world != null && instanceId != null) { + for (UUID uuid : NeedsOfNature.getInstanceActors(world, instanceId)) { + Entity e = world.getEntity(uuid); + if (e != null) { + combined.add(e); + } + } + } + + if (combined.stream().noneMatch(ex -> ex.getUuid().equals(this.getUuid()))) { + combined.add(this); + } + + combined.sort(Comparator.comparingInt(Entity::getId)); + return combined; + } + + @Unique + private void non$applyRollCooldown(UUID partnerId, long nowTick) { + if (partnerId != null) { + long until = nowTick + 1200L; + this.non$partnerCooldowns.put(partnerId, until); + this.non$bypassablePartnerCooldowns.put(partnerId, until); + } + } + + @Unique + private void non$applyNonBypassRollCooldown(UUID partnerId, long nowTick) { + if (partnerId != null) { + long until = nowTick + 1200L; + this.non$multiActorJoinCooldowns.put(partnerId, until); + } + } + + @Unique + private void non$pruneCooldownMaps(long nowTick) { + this.non$partnerCooldowns.entrySet().removeIf(e -> e.getValue() <= nowTick); + this.non$bypassablePartnerCooldowns.entrySet().removeIf(e -> e.getValue() <= nowTick); + this.non$multiActorJoinCooldowns.entrySet().removeIf(e -> e.getValue() <= nowTick); + } + + @Unique + private void non$ensureEnergyInitialized(ServerWorld world) { + if (!this.non$energyInitialized) { + this.non$energyInitialized = true; + if (this.non$energyGainMult <= 0.0F) { + this.non$energyGainMult = NonMobEnergySystem.randomGainMultiplier(world); + } + + if (this.non$energyGainDrift <= 0.0F) { + this.non$energyGainDrift = NonMobEnergySystem.randomGainDrift(world); + } + + int maxInitial = NeedsOfNature.getConfig().getInitialEnergyMax(); + if (maxInitial > 0 && this.getEnergy() == 0) { + int initial = world.getRandom().nextInt(maxInitial + 1); + this.setEnergy(initial); + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/MobEntityGenderMixin.java b/src/main/java/com/nonid/mixin/MobEntityGenderMixin.java new file mode 100644 index 0000000..92197bd --- /dev/null +++ b/src/main/java/com/nonid/mixin/MobEntityGenderMixin.java @@ -0,0 +1,41 @@ +package com.nonid.mixin; + +import com.nonid.GenderHolder; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.data.DataTracker.Builder; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.world.World; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(MobEntity.class) +public abstract class MobEntityGenderMixin extends LivingEntity implements GenderHolder { + @Unique + private static final TrackedData NON_GENDER = DataTracker.registerData(MobEntity.class, TrackedDataHandlerRegistry.INTEGER); + + protected MobEntityGenderMixin(EntityType entityType, World world) { + super(entityType, world); + } + + @Inject(method = "initDataTracker", at = @At("TAIL")) + private void needsOfNature$initGenderTracker(Builder builder, CallbackInfo ci) { + builder.add(NON_GENDER, 0); + } + + @Override + public int getGenderMask() { + return (Integer)this.dataTracker.get(NON_GENDER); + } + + @Override + public void setGenderMask(int mask) { + this.dataTracker.set(NON_GENDER, mask & 3); + } +} diff --git a/src/main/java/com/nonid/mixin/MobEntityPregnancyFriendlyTargetMixin.java b/src/main/java/com/nonid/mixin/MobEntityPregnancyFriendlyTargetMixin.java new file mode 100644 index 0000000..32222b2 --- /dev/null +++ b/src/main/java/com/nonid/mixin/MobEntityPregnancyFriendlyTargetMixin.java @@ -0,0 +1,37 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.mob.Angerable; +import net.minecraft.entity.mob.MobEntity; +import net.minecraft.entity.player.PlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(MobEntity.class) +public abstract class MobEntityPregnancyFriendlyTargetMixin { + @Inject(method = "setTarget", at = @At("HEAD"), cancellable = true) + private void non$blockPlayerTargetingForPregnancyFriendly(LivingEntity target, CallbackInfo ci) { + if (target instanceof PlayerEntity) { + MobEntity mob = (MobEntity)(Object)this; + if (NeedsOfNature.isPregnancyFriendlyMob(mob)) { + ci.cancel(); + } + } + } + + @Inject(method = "mobTick", at = @At("HEAD")) + private void non$clearPlayerTargetForPregnancyFriendly(CallbackInfo ci) { + MobEntity mob = (MobEntity)(Object)this; + if (NeedsOfNature.isPregnancyFriendlyMob(mob)) { + if (mob.getTarget() instanceof PlayerEntity) { + mob.setTarget(null); + if (mob instanceof Angerable angerable) { + angerable.stopAnger(); + } + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/NonMixinPlugin.java b/src/main/java/com/nonid/mixin/NonMixinPlugin.java new file mode 100644 index 0000000..b397a98 --- /dev/null +++ b/src/main/java/com/nonid/mixin/NonMixinPlugin.java @@ -0,0 +1,36 @@ +package com.nonid.mixin; + +import java.util.List; +import java.util.Set; +import net.fabricmc.loader.api.FabricLoader; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +public final class NonMixinPlugin implements IMixinConfigPlugin { + private static final String TRINKETS_SLOT_STATE_MIXIN = "com.nonid.mixin.client.HandledScreenTrinketSlotStateMixin"; + + public void onLoad(String mixinPackage) { + } + + public String getRefMapperConfig() { + return null; + } + + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + return "com.nonid.mixin.client.HandledScreenTrinketSlotStateMixin".equals(mixinClassName) ? FabricLoader.getInstance().isModLoaded("trinkets") : true; + } + + public void acceptTargets(Set myTargets, Set otherTargets) { + } + + public List getMixins() { + return null; + } + + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } + + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerEntityDestroyedSkinMixin.java b/src/main/java/com/nonid/mixin/PlayerEntityDestroyedSkinMixin.java new file mode 100644 index 0000000..294a93c --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerEntityDestroyedSkinMixin.java @@ -0,0 +1,102 @@ +package com.nonid.mixin; + +import com.nonid.DestroyedSkinHolder; +import com.nonid.MessHolder; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.data.DataTracker.Builder; +import net.minecraft.entity.player.PlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public abstract class PlayerEntityDestroyedSkinMixin implements DestroyedSkinHolder, MessHolder { + @Unique + private static final TrackedData NON_DESTROYED_SKIN_STAGE = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private static final TrackedData NON_DESTROYED_SKIN_DAMAGE = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private static final TrackedData NON_MESS_V = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private static final TrackedData NON_MESS_A = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private static final TrackedData NON_MESS_M = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + + @Inject(method = "initDataTracker", at = @At("TAIL")) + private void non$initDestroyedSkinTracker(Builder builder, CallbackInfo ci) { + builder.add(NON_DESTROYED_SKIN_STAGE, 0); + builder.add(NON_DESTROYED_SKIN_DAMAGE, 0); + builder.add(NON_MESS_V, 0); + builder.add(NON_MESS_A, 0); + builder.add(NON_MESS_M, 0); + } + + @Override + public int getDestroyedSkinStage() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_DESTROYED_SKIN_STAGE); + } + + @Override + public void setDestroyedSkinStage(int stage) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_DESTROYED_SKIN_STAGE, Math.max(0, Math.min(4, stage))); + } + + @Override + public int getDestroyedSkinDamage() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_DESTROYED_SKIN_DAMAGE); + } + + @Override + public void setDestroyedSkinDamage(int damage) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_DESTROYED_SKIN_DAMAGE, Math.max(0, Math.min(10, damage))); + } + + @Override + public int getVMess() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_MESS_V); + } + + @Override + public void setVMess(int value) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_MESS_V, clampMess(value)); + } + + @Override + public int getAMess() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_MESS_A); + } + + @Override + public void setAMess(int value) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_MESS_A, clampMess(value)); + } + + @Override + public int getMMess() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_MESS_M); + } + + @Override + public void setMMess(int value) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_MESS_M, clampMess(value)); + } + + @Unique + private static int clampMess(int value) { + return Math.max(0, Math.min(10, value)); + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerEntityEnergyMixin.java b/src/main/java/com/nonid/mixin/PlayerEntityEnergyMixin.java new file mode 100644 index 0000000..11614d0 --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerEntityEnergyMixin.java @@ -0,0 +1,181 @@ +package com.nonid.mixin; + +import com.nonid.EnergyHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonTrinketsIntegration; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.data.DataTracker.Builder; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public abstract class PlayerEntityEnergyMixin implements EnergyHolder { + @Unique + private static final TrackedData ENERGY = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + @Unique + private static final int DEFAULT_TICKS_PER_ENERGY = 120; + @Unique + private static final int WATER_ENERGY_DECAY_TICKS = 10; + @Unique + private int non$energyTickCounter; + @Unique + private int non$waterEnergyDecayTickCounter; + @Unique + private boolean non$energyInitialized; + @Unique + private float non$energyGainMult = 1.0F; + @Unique + private float non$energyGainDrift = 1.0F; + + @Inject(method = "initDataTracker", at = @At("TAIL")) + private void needsOfNature$initEnergyTracker(Builder builder, CallbackInfo ci) { + builder.add(ENERGY, 0); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void needsOfNature$tickEnergy(CallbackInfo ci) { + PlayerEntity self = (PlayerEntity)(Object)this; + if (!self.getEntityWorld().isClient()) { + if (self.getEntityWorld() instanceof ServerWorld serverWorld) { + this.ensureEnergyInitialized(serverWorld); + if (self instanceof ServerPlayerEntity serverPlayer) { + NonTrinketsIntegration.syncAccessoryStateIfChanged(serverPlayer); + } + + int energy = this.getEnergy(); + energy = this.non$applyWaterEnergyDecay(self, energy); + int ticksPerGain = this.non$getTicksPerGain(); + if (energy >= 200) { + this.non$energyTickCounter = 0; + if (self instanceof ServerPlayerEntity serverPlayer) { + NeedsOfNature.updateEnergizedEffect(serverPlayer, this); + NeedsOfNature.tickPlayerEnergyAura(serverWorld, serverPlayer); + } + } else { + this.non$energyTickCounter++; + if (this.non$energyTickCounter >= ticksPerGain) { + this.non$energyTickCounter = 0; + this.setEnergy(Math.min(200, energy + 1)); + } + + if (self instanceof ServerPlayerEntity serverPlayer) { + NeedsOfNature.updateEnergizedEffect(serverPlayer, this); + NeedsOfNature.tickPlayerEnergyAura(serverWorld, serverPlayer); + } + } + } + } + } + + @Unique + private int non$applyWaterEnergyDecay(PlayerEntity self, int energy) { + if (self.isTouchingWater() && energy > 0) { + this.non$waterEnergyDecayTickCounter++; + if (this.non$waterEnergyDecayTickCounter < 10) { + return energy; + } + + this.non$waterEnergyDecayTickCounter = 0; + int next = Math.max(0, energy - 1); + this.setEnergy(next); + return next; + } else { + this.non$waterEnergyDecayTickCounter = 0; + return energy; + } + } + + @Override + public int getEnergy() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(ENERGY); + } + + @Override + public int getMaxEnergy() { + return 200; + } + + @Override + public void setEnergy(int value) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(ENERGY, Math.min(200, Math.max(0, value))); + } + + @Override + public float getEnergyGainMultiplier() { + return this.non$energyGainMult <= 0.0F ? 1.0F : this.non$energyGainMult; + } + + @Override + public void setEnergyGainMultiplier(float value) { + if (Float.isFinite(value)) { + this.non$energyGainMult = Math.max(0.1F, Math.min(10.0F, value)); + } + } + + @Override + public float getEnergyGainDrift() { + return this.non$energyGainDrift <= 0.0F ? 1.0F : this.non$energyGainDrift; + } + + @Override + public void setEnergyGainDrift(float value) { + if (Float.isFinite(value)) { + this.non$energyGainDrift = Math.max(0.1F, Math.min(10.0F, value)); + } + } + + @Override + public boolean isEnergyInitialized() { + return this.non$energyInitialized; + } + + @Override + public void markEnergyInitialized() { + this.non$energyInitialized = true; + } + + @Override + public void ensureEnergyInitialized(ServerWorld world) { + if (!this.non$energyInitialized) { + this.non$energyInitialized = true; + if (this.non$energyGainMult <= 0.0F) { + this.non$energyGainMult = 1.0F; + } + + if (this.non$energyGainDrift <= 0.0F) { + this.non$energyGainDrift = 1.0F; + } + + if (this.getEnergy() < 0) { + this.setEnergy(0); + } + } + } + + @Unique + private int non$getTicksPerGain() { + double rate = NeedsOfNature.getConfig().getEnergyGainRate(); + float scale = this.getEnergyGainMultiplier() * this.getEnergyGainDrift() * (float)rate; + PlayerEntity self = (PlayerEntity)(Object)this; + if (self instanceof ServerPlayerEntity serverPlayer) { + scale = (float)(scale * NonTrinketsIntegration.getAccessoryEffects(serverPlayer).playerEnergyGainMultiplier()); + } + + if (Float.isFinite(scale) && !(scale <= 0.0F)) { + int effective = Math.round(120.0F / scale); + return Math.max(1, effective); + } else { + return 120; + } + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerEntityGenderMixin.java b/src/main/java/com/nonid/mixin/PlayerEntityGenderMixin.java new file mode 100644 index 0000000..327ca58 --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerEntityGenderMixin.java @@ -0,0 +1,36 @@ +package com.nonid.mixin; + +import com.nonid.GenderHolder; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.data.DataTracker.Builder; +import net.minecraft.entity.player.PlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public abstract class PlayerEntityGenderMixin implements GenderHolder { + @Unique + private static final TrackedData NON_GENDER = DataTracker.registerData(PlayerEntity.class, TrackedDataHandlerRegistry.INTEGER); + + @Inject(method = "initDataTracker", at = @At("TAIL")) + private void non$initGenderTracker(Builder builder, CallbackInfo ci) { + builder.add(NON_GENDER, 0); + } + + @Override + public int getGenderMask() { + PlayerEntity self = (PlayerEntity)(Object)this; + return (Integer)self.getDataTracker().get(NON_GENDER); + } + + @Override + public void setGenderMask(int mask) { + PlayerEntity self = (PlayerEntity)(Object)this; + self.getDataTracker().set(NON_GENDER, mask & 3); + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerLiquidMixin.java b/src/main/java/com/nonid/mixin/PlayerLiquidMixin.java new file mode 100644 index 0000000..0855ec4 --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerLiquidMixin.java @@ -0,0 +1,177 @@ +package com.nonid.mixin; + +import com.nonid.LiquidDonorHistory; +import com.nonid.LiquidHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import com.nonid.NonTrinketsIntegration; +import java.util.List; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class PlayerLiquidMixin implements LiquidHolder { + @Unique + private int nonLiquidStored = 0; + @Unique + private boolean nonLiquidMixed = false; + @Unique + private Identifier nonLiquidEntityType = null; + @Unique + private NeedsOfNature.PregnancyVariantData nonLiquidDonorVariantData = null; + @Unique + private List nonRecentLiquidDonors = List.of(); + + @Override + public int getLiquidStored() { + return this.nonLiquidStored; + } + + @Override + public int getLiquidCapacity() { + NonConfig config = NeedsOfNature.getConfig(); + if (config != null && !config.isLiquidTankEnabled()) { + return 0; + } + + int baseCapacity = config != null ? config.getLiquidTankCapacityMl() : 200; + if ((Object)this instanceof PlayerEntity player) { + baseCapacity += NonTrinketsIntegration.getActiveTankAccessoryEffects(player).liquidCapacityAdd(); + } + + return Math.max(0, baseCapacity); + } + + @Override + public void setLiquidStored(int value) { + int cap = this.getLiquidCapacity(); + if (cap <= 0) { + this.nonLiquidStored = 0; + this.clearLiquidComposition(); + this.clearRecentLiquidDonors(); + } else { + this.nonLiquidStored = Math.max(0, Math.min(cap, value)); + if (this.nonLiquidStored == 0) { + this.clearLiquidComposition(); + this.clearRecentLiquidDonors(); + } + } + } + + @Override + public LiquidHolder.LiquidComposition getLiquidComposition() { + if (this.nonLiquidStored == 0) { + return LiquidHolder.LiquidComposition.EMPTY; + } else if (this.nonLiquidMixed) { + return LiquidHolder.LiquidComposition.MIXED; + } else { + return this.nonLiquidEntityType != null ? LiquidHolder.LiquidComposition.ENTITY : LiquidHolder.LiquidComposition.MIXED; + } + } + + @Override + public Identifier getLiquidEntityTypeId() { + return this.nonLiquidEntityType; + } + + @Override + public NeedsOfNature.PregnancyVariantData getLiquidDonorVariantData() { + return this.nonLiquidDonorVariantData; + } + + @Override + public void setLiquidCompositionMixed() { + this.nonLiquidMixed = true; + this.nonLiquidEntityType = null; + this.nonLiquidDonorVariantData = null; + } + + @Override + public void setLiquidCompositionEntity(Identifier entityTypeId) { + if (entityTypeId == null) { + this.setLiquidCompositionMixed(); + } else { + this.nonLiquidMixed = false; + this.nonLiquidEntityType = entityTypeId; + } + } + + @Override + public void setLiquidDonorVariantData(NeedsOfNature.PregnancyVariantData variantData) { + this.nonLiquidDonorVariantData = NeedsOfNature.normalizePregnancyVariantData(variantData); + } + + @Override + public List getRecentLiquidDonors() { + return this.nonRecentLiquidDonors; + } + + @Override + public void setRecentLiquidDonors(List donors) { + this.nonRecentLiquidDonors = LiquidDonorHistory.normalize(donors); + } + + @Override + public void clearLiquidComposition() { + this.nonLiquidMixed = false; + this.nonLiquidEntityType = null; + this.nonLiquidDonorVariantData = null; + } + + @Inject(method = "writeData", at = @At("TAIL")) + private void needsOfNature$writeLiquid(WriteView storage, CallbackInfo ci) { + if ((Object)this instanceof PlayerEntity) { + if (this.nonLiquidStored > 0) { + storage.putInt("NeedsOfNatureLiquid", this.nonLiquidStored); + if (this.nonLiquidMixed) { + storage.putString("NeedsOfNatureLiquidType", "mixed"); + } else if (this.nonLiquidEntityType != null) { + storage.putString("NeedsOfNatureLiquidType", this.nonLiquidEntityType.toString()); + String variantJson = NeedsOfNature.encodePregnancyVariantData(this.nonLiquidDonorVariantData); + if (variantJson != null) { + storage.putString("NeedsOfNatureLiquidDonorVariantData", variantJson); + } + } + + String donorHistoryJson = LiquidDonorHistory.encode(this.nonRecentLiquidDonors); + if (donorHistoryJson != null) { + storage.putString("NeedsOfNatureLiquidDonorHistory", donorHistoryJson); + } + } + } + } + + @Inject(method = "readData", at = @At("TAIL")) + private void needsOfNature$readLiquid(ReadView storage, CallbackInfo ci) { + if ((Object)this instanceof PlayerEntity) { + int stored = storage.getOptionalInt("NeedsOfNatureLiquid").orElse(0); + this.setLiquidStored(stored); + if (this.nonLiquidStored > 0) { + String rawType = storage.getOptionalString("NeedsOfNatureLiquidType").orElse(""); + if (!rawType.isBlank() && !"mixed".equalsIgnoreCase(rawType)) { + Identifier entityTypeId = Identifier.tryParse(rawType); + if (entityTypeId != null) { + this.setLiquidCompositionEntity(entityTypeId); + this.setLiquidDonorVariantData( + NeedsOfNature.decodePregnancyVariantData(storage.getOptionalString("NeedsOfNatureLiquidDonorVariantData").orElse("")) + ); + } else { + this.setLiquidCompositionMixed(); + } + } else { + this.setLiquidCompositionMixed(); + } + + this.setRecentLiquidDonors(LiquidDonorHistory.decode(storage.getOptionalString("NeedsOfNatureLiquidDonorHistory").orElse(""))); + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerLiquidMovementMixin.java b/src/main/java/com/nonid/mixin/PlayerLiquidMovementMixin.java new file mode 100644 index 0000000..d7c18a1 --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerLiquidMovementMixin.java @@ -0,0 +1,70 @@ +package com.nonid.mixin; + +import com.nonid.LiquidHolder; +import com.nonid.NeedsOfNature; +import com.nonid.NonTrinketsIntegration; +import com.nonid.effect.NonStatusEffects; +import net.minecraft.entity.attribute.EntityAttributeInstance; +import net.minecraft.entity.attribute.EntityAttributeModifier; +import net.minecraft.entity.attribute.EntityAttributes; +import net.minecraft.entity.attribute.EntityAttributeModifier.Operation; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public abstract class PlayerLiquidMovementMixin { + @Unique + private static final Identifier LIQUID_SPEED_MOD = NeedsOfNature.id("liquid_speed"); + @Unique + private static final Identifier PREGNANCY_SPEED_MOD = NeedsOfNature.id("pregnancy_speed"); + + @Inject(method = "tickMovement", at = @At("TAIL")) + private void needsOfNature$applyLiquidMovementSlowdown(CallbackInfo ci) { + PlayerEntity player = (PlayerEntity)(Object)this; + if (player instanceof LiquidHolder) { + EntityAttributeInstance attribute = player.getAttributeInstance(EntityAttributes.MOVEMENT_SPEED); + if (attribute != null) { + StatusEffectInstance effect = player.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FILLED)); + if (effect == null) { + attribute.removeModifier(LIQUID_SPEED_MOD); + } else { + int amplifier = effect.getAmplifier(); + float multiplier = amplifier >= 2 + ? NeedsOfNature.getConfig().getFilledStageThreeSpeedMult() + : (amplifier >= 1 ? NeedsOfNature.getConfig().getFilledStageTwoSpeedMult() : NeedsOfNature.getConfig().getFilledStageOneSpeedMult()); + multiplier = non$applyFilledAccessoryMultiplier(player, multiplier); + double value = multiplier - 1.0; + attribute.updateModifier(new EntityAttributeModifier(LIQUID_SPEED_MOD, value, Operation.ADD_MULTIPLIED_TOTAL)); + } + + if (!player.getEntityWorld().isClient()) { + StatusEffectInstance pregnancy = player.getStatusEffect(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.PREGNANT)); + if (pregnancy == null) { + attribute.removeModifier(PREGNANCY_SPEED_MOD); + } else { + float pregnancyMultiplier = NeedsOfNature.getPregnancyMovementMultiplier(pregnancy.getDuration()); + attribute.updateModifier(new EntityAttributeModifier(PREGNANCY_SPEED_MOD, pregnancyMultiplier - 1.0, Operation.ADD_MULTIPLIED_TOTAL)); + } + } + } + } + } + + @Unique + private static float non$applyFilledAccessoryMultiplier(PlayerEntity player, float multiplier) { + double effectMultiplier = NonTrinketsIntegration.getActiveTankAccessoryEffects(player).filledEffectMultiplier(); + if (!Double.isFinite(effectMultiplier)) { + return multiplier; + } + + double penalty = 1.0 - multiplier; + return (float)Math.max(0.0, Math.min(2.0, 1.0 - penalty * effectMultiplier)); + } +} diff --git a/src/main/java/com/nonid/mixin/PlayerPregnancyMixin.java b/src/main/java/com/nonid/mixin/PlayerPregnancyMixin.java new file mode 100644 index 0000000..043b0a6 --- /dev/null +++ b/src/main/java/com/nonid/mixin/PlayerPregnancyMixin.java @@ -0,0 +1,188 @@ +package com.nonid.mixin; + +import com.mojang.serialization.Codec; +import com.nonid.NeedsOfNature; +import com.nonid.PregnancyCycleHolder; +import com.nonid.PregnancyHolder; +import java.util.Optional; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.storage.ReadView; +import net.minecraft.storage.WriteView; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Entity.class) +public abstract class PlayerPregnancyMixin implements PregnancyHolder, PregnancyCycleHolder { + @Unique + @Nullable + private Identifier nonPregnancyEntityType; + @Unique + private long nonPregnancyEndTick; + @Unique + private boolean nonPregnancyPendingHatch; + @Unique + @Nullable + private NeedsOfNature.PregnancyVariantData nonPregnancyVariantData; + @Unique + private int nonPregnancyOffspringCount; + @Unique + private boolean nonPregnancyCycleInitialized; + @Unique + private long nonPregnancyCycleProgressTicks; + @Unique + private long nonPregnancyCycleLastGameTick; + @Unique + private long nonPregnancyCycleLastTimeOfDay; + + @Nullable + @Override + public Identifier getPregnantEntityTypeId() { + return this.nonPregnancyEntityType; + } + + @Override + public long getPregnancyEndTick() { + return this.nonPregnancyEndTick; + } + + @Override + public boolean isPregnancyPendingHatch() { + return this.nonPregnancyPendingHatch; + } + + @Nullable + @Override + public NeedsOfNature.PregnancyVariantData getPregnancyVariantData() { + return this.nonPregnancyVariantData; + } + + @Override + public int getPregnancyOffspringCount() { + return this.nonPregnancyOffspringCount; + } + + @Override + public void setPregnancyOffspringCount(int offspringCount) { + this.nonPregnancyOffspringCount = Math.max(0, Math.min(16, offspringCount)); + } + + @Override + public void setPregnancyState( + @Nullable Identifier entityTypeId, long endTick, boolean pendingHatch, @Nullable NeedsOfNature.PregnancyVariantData variantData + ) { + if (entityTypeId == null) { + this.clearPregnancyState(); + } else { + this.nonPregnancyEntityType = entityTypeId; + this.nonPregnancyEndTick = Math.max(0L, endTick); + this.nonPregnancyPendingHatch = pendingHatch; + this.nonPregnancyVariantData = NeedsOfNature.normalizePregnancyVariantData(variantData); + } + } + + @Override + public void clearPregnancyState() { + this.nonPregnancyEntityType = null; + this.nonPregnancyEndTick = 0L; + this.nonPregnancyPendingHatch = false; + this.nonPregnancyVariantData = null; + this.nonPregnancyOffspringCount = 0; + } + + @Override + public boolean isPregnancyCycleInitialized() { + return this.nonPregnancyCycleInitialized; + } + + @Override + public long getPregnancyCycleProgressTicks() { + return this.nonPregnancyCycleProgressTicks; + } + + @Override + public long getPregnancyCycleLastGameTick() { + return this.nonPregnancyCycleLastGameTick; + } + + @Override + public long getPregnancyCycleLastTimeOfDay() { + return this.nonPregnancyCycleLastTimeOfDay; + } + + @Override + public void setPregnancyCycleState(long progressTicks, long lastGameTick, long lastTimeOfDay) { + this.nonPregnancyCycleInitialized = true; + this.nonPregnancyCycleProgressTicks = Math.max(0L, progressTicks); + this.nonPregnancyCycleLastGameTick = Math.max(0L, lastGameTick); + this.nonPregnancyCycleLastTimeOfDay = Math.max(0L, lastTimeOfDay); + } + + @Override + public void clearPregnancyCycleState() { + this.nonPregnancyCycleInitialized = false; + this.nonPregnancyCycleProgressTicks = 0L; + this.nonPregnancyCycleLastGameTick = 0L; + this.nonPregnancyCycleLastTimeOfDay = 0L; + } + + @Inject(method = "writeData", at = @At("TAIL")) + private void non$writePregnancy(WriteView storage, CallbackInfo ci) { + if ((Object)this instanceof PlayerEntity) { + if (this.nonPregnancyCycleInitialized) { + storage.put("NeedsOfNaturePregnancyCycleProgress", Codec.LONG, Math.max(0L, this.nonPregnancyCycleProgressTicks)); + storage.put("NeedsOfNaturePregnancyCycleLastGameTick", Codec.LONG, Math.max(0L, this.nonPregnancyCycleLastGameTick)); + storage.put("NeedsOfNaturePregnancyCycleLastTimeOfDay", Codec.LONG, Math.max(0L, this.nonPregnancyCycleLastTimeOfDay)); + } + + if (this.nonPregnancyEntityType != null) { + storage.putString("NeedsOfNaturePregnancyType", this.nonPregnancyEntityType.toString()); + storage.put("NeedsOfNaturePregnancyEndTick", Codec.LONG, Math.max(0L, this.nonPregnancyEndTick)); + storage.put("NeedsOfNaturePregnancyPending", Codec.BOOL, this.nonPregnancyPendingHatch); + if (this.nonPregnancyOffspringCount > 0) { + storage.putInt("NeedsOfNaturePregnancyOffspringCount", this.nonPregnancyOffspringCount); + } + + String variantJson = NeedsOfNature.encodePregnancyVariantData(this.nonPregnancyVariantData); + if (variantJson != null) { + storage.putString("NeedsOfNaturePregnancyVariantData", variantJson); + } + } + } + } + + @Inject(method = "readData", at = @At("TAIL")) + private void non$readPregnancy(ReadView storage, CallbackInfo ci) { + if ((Object)this instanceof PlayerEntity) { + Optional cycleProgress = storage.read("NeedsOfNaturePregnancyCycleProgress", Codec.LONG); + if (cycleProgress.isPresent()) { + this.setPregnancyCycleState( + cycleProgress.get(), + storage.read("NeedsOfNaturePregnancyCycleLastGameTick", Codec.LONG).orElse(0L), + storage.read("NeedsOfNaturePregnancyCycleLastTimeOfDay", Codec.LONG).orElse(0L) + ); + } else { + this.clearPregnancyCycleState(); + } + + String rawType = storage.getOptionalString("NeedsOfNaturePregnancyType").orElse(""); + Identifier entityTypeId = rawType.isBlank() ? null : Identifier.tryParse(rawType); + if (entityTypeId == null) { + this.clearPregnancyState(); + } else { + long endTick = Math.max(0L, storage.read("NeedsOfNaturePregnancyEndTick", Codec.LONG).orElse(0L)); + boolean pending = storage.read("NeedsOfNaturePregnancyPending", Codec.BOOL).orElse(false); + NeedsOfNature.PregnancyVariantData variantData = NeedsOfNature.decodePregnancyVariantData( + storage.getOptionalString("NeedsOfNaturePregnancyVariantData").orElse("") + ); + this.setPregnancyState(entityTypeId, endTick, pending, variantData); + this.setPregnancyOffspringCount(storage.getOptionalInt("NeedsOfNaturePregnancyOffspringCount").orElse(0)); + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/ProjectileEntityAnimationRescueMixin.java b/src/main/java/com/nonid/mixin/ProjectileEntityAnimationRescueMixin.java new file mode 100644 index 0000000..803aa8a --- /dev/null +++ b/src/main/java/com/nonid/mixin/ProjectileEntityAnimationRescueMixin.java @@ -0,0 +1,26 @@ +package com.nonid.mixin; + +import com.nonid.NonAnimationRescueSystem; +import net.minecraft.entity.Entity; +import net.minecraft.entity.projectile.ProjectileEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.hit.EntityHitResult; +import net.minecraft.util.hit.HitResult; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(ProjectileEntity.class) +public abstract class ProjectileEntityAnimationRescueMixin { + @ModifyVariable(method = "hitOrDeflect", at = @At("HEAD"), argsOnly = true) + private HitResult non$redirectAnimatedPlayerHit(HitResult hitResult) { + ProjectileEntity projectile = (ProjectileEntity)(Object)this; + if (projectile.getEntityWorld() instanceof ServerWorld world && hitResult instanceof EntityHitResult entityHit) { + Entity var7 = entityHit.getEntity(); + Entity redirectedTarget = NonAnimationRescueSystem.redirectProjectileTarget(world, projectile, var7); + return (HitResult)(redirectedTarget == var7 ? hitResult : new EntityHitResult(redirectedTarget, entityHit.getPos())); + } else { + return hitResult; + } + } +} diff --git a/src/main/java/com/nonid/mixin/ResourcePackManagerNoNPackMixin.java b/src/main/java/com/nonid/mixin/ResourcePackManagerNoNPackMixin.java new file mode 100644 index 0000000..9b527d7 --- /dev/null +++ b/src/main/java/com/nonid/mixin/ResourcePackManagerNoNPackMixin.java @@ -0,0 +1,135 @@ +package com.nonid.mixin; + +import com.google.common.collect.ImmutableSet; +import com.nonid.pack.NonExternalPackProvider; +import com.nonid.pack.NonGeneratedAccessoryModelPackProvider; +import com.nonid.pack.NonGeneratedAccessoryTagPackProvider; +import com.nonid.pack.NonGeneratedCemPackProvider; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import net.minecraft.resource.FileResourcePackProvider; +import net.minecraft.resource.ResourcePackManager; +import net.minecraft.resource.ResourcePackProvider; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.path.SymlinkFinder; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ResourcePackManager.class) +public abstract class ResourcePackManagerNoNPackMixin { + @Shadow + @Final + @Mutable + private Set providers; + + @Inject(method = "", at = @At("RETURN")) + private void non$injectExternalNoNPacks(ResourcePackProvider[] originalProviders, CallbackInfo ci) { + boolean needsClientProvider = false; + boolean needsServerProvider = false; + SymlinkFinder clientSymlinkFinder = null; + SymlinkFinder serverSymlinkFinder = null; + + for (ResourcePackProvider provider : this.providers) { + if (provider instanceof FileResourcePackProvider fileProvider) { + FileResourcePackProviderAccessor accessor = (FileResourcePackProviderAccessor)fileProvider; + ResourceType type = accessor.non$getType(); + if (type == ResourceType.CLIENT_RESOURCES) { + needsClientProvider = true; + if (clientSymlinkFinder == null) { + clientSymlinkFinder = accessor.non$getSymlinkFinder(); + } + } else if (type == ResourceType.SERVER_DATA) { + needsServerProvider = true; + if (serverSymlinkFinder == null) { + serverSymlinkFinder = accessor.non$getSymlinkFinder(); + } + } + } + } + + if (needsClientProvider || needsServerProvider) { + Path nonPackDir = NonExternalPackProvider.resolveDefaultPacksDir(); + LinkedHashSet updated = new LinkedHashSet<>(this.providers); + if (needsClientProvider && !containsNoNProvider(updated, ResourceType.CLIENT_RESOURCES)) { + updated.add( + new NonExternalPackProvider( + nonPackDir, ResourceType.CLIENT_RESOURCES, clientSymlinkFinder != null ? clientSymlinkFinder : new SymlinkFinder(path -> true) + ) + ); + } + + if (needsClientProvider && !containsGeneratedCemProvider(updated)) { + updated.add(new NonGeneratedCemPackProvider(nonPackDir)); + } + + if (needsClientProvider && !containsGeneratedAccessoryModelProvider(updated)) { + updated.add(new NonGeneratedAccessoryModelPackProvider()); + } + + if (needsServerProvider && !containsNoNProvider(updated, ResourceType.SERVER_DATA)) { + updated.add( + new NonExternalPackProvider( + nonPackDir, ResourceType.SERVER_DATA, serverSymlinkFinder != null ? serverSymlinkFinder : new SymlinkFinder(path -> true) + ) + ); + } + + if (needsServerProvider && !containsGeneratedAccessoryTagProvider(updated)) { + updated.add(new NonGeneratedAccessoryTagPackProvider()); + } + + this.providers = ImmutableSet.copyOf(updated); + } + } + + @Unique + private static boolean containsNoNProvider(Set providers, ResourceType type) { + for (ResourcePackProvider provider : providers) { + if (provider instanceof NonExternalPackProvider nonProvider && nonProvider.getType() == type) { + return true; + } + } + + return false; + } + + @Unique + private static boolean containsGeneratedCemProvider(Set providers) { + for (ResourcePackProvider provider : providers) { + if (provider instanceof NonGeneratedCemPackProvider) { + return true; + } + } + + return false; + } + + @Unique + private static boolean containsGeneratedAccessoryTagProvider(Set providers) { + for (ResourcePackProvider provider : providers) { + if (provider instanceof NonGeneratedAccessoryTagPackProvider) { + return true; + } + } + + return false; + } + + @Unique + private static boolean containsGeneratedAccessoryModelProvider(Set providers) { + for (ResourcePackProvider provider : providers) { + if (provider instanceof NonGeneratedAccessoryModelPackProvider) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/nonid/mixin/ServerPlayerEntityDamageMixin.java b/src/main/java/com/nonid/mixin/ServerPlayerEntityDamageMixin.java new file mode 100644 index 0000000..2b38209 --- /dev/null +++ b/src/main/java/com/nonid/mixin/ServerPlayerEntityDamageMixin.java @@ -0,0 +1,19 @@ +package com.nonid.mixin; + +import com.nonid.NeedsOfNature; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ServerPlayerEntity.class) +public abstract class ServerPlayerEntityDamageMixin { + @Redirect( + method = "damage", + at = @At(value = "INVOKE", target = "Lnet/minecraft/server/network/ServerPlayerEntity;shouldDamagePlayer(Lnet/minecraft/entity/player/PlayerEntity;)Z") + ) + private boolean non$allowDefeatedFinishDamage(ServerPlayerEntity victim, PlayerEntity attacker) { + return NeedsOfNature.shouldBypassVanillaPvpForDefeatedFinish(victim, attacker) ? true : victim.shouldDamagePlayer(attacker); + } +} diff --git a/src/main/java/com/nonid/mixin/client/EntityRendererDebugNameMixin.java b/src/main/java/com/nonid/mixin/client/EntityRendererDebugNameMixin.java new file mode 100644 index 0000000..afc059b --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/EntityRendererDebugNameMixin.java @@ -0,0 +1,51 @@ +package com.nonid.mixin.client; + +import com.nonid.EnergyHolder; +import com.nonid.client.NonDebugNameHelper; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.state.EntityRenderState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityAttachmentType; +import net.minecraft.text.Text; +import net.minecraft.util.math.Vec3d; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(EntityRenderer.class) +public abstract class EntityRendererDebugNameMixin { + @Inject(method = "hasLabel", at = @At("HEAD"), cancellable = true) + private void needsOfNature$showDebugLabel(Entity entity, double distance, CallbackInfoReturnable cir) { + if (NonDebugNameHelper.shouldShowDebugLabel(entity)) { + cir.setReturnValue(true); + } + } + + @Inject(method = "getDisplayName", at = @At("HEAD"), cancellable = true) + private void needsOfNature$debugDisplayName(Entity entity, CallbackInfoReturnable cir) { + if (NonDebugNameHelper.shouldShowDebugLabel(entity)) { + if (entity instanceof EnergyHolder holder) { + String energy = holder.getEnergy() + "/" + holder.getMaxEnergy(); + cir.setReturnValue(Text.literal("T:" + energy + " " + NonDebugNameHelper.resolveGenderSymbol(entity))); + } + } + } + + @Inject(method = "updateRenderState", at = @At("TAIL")) + private void needsOfNature$debugLabelState(Entity entity, EntityRenderState state, float tickDelta, CallbackInfo ci) { + if (NonDebugNameHelper.shouldShowDebugLabel(entity)) { + if (entity instanceof EnergyHolder holder) { + String energy = holder.getEnergy() + "/" + holder.getMaxEnergy(); + state.displayName = Text.literal("T:" + energy + " " + NonDebugNameHelper.resolveGenderSymbol(entity)); + Vec3d labelPos = entity.getAttachments().getPointNullable(EntityAttachmentType.NAME_TAG, 0, entity.getLerpedYaw(tickDelta)); + if (labelPos == null) { + labelPos = new Vec3d(entity.getX(), entity.getY() + entity.getHeight() + 0.5, entity.getZ()); + } + + state.nameLabelPos = labelPos; + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/GameRendererReactiveImpactFovMixin.java b/src/main/java/com/nonid/mixin/client/GameRendererReactiveImpactFovMixin.java new file mode 100644 index 0000000..dfd6db4 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/GameRendererReactiveImpactFovMixin.java @@ -0,0 +1,20 @@ +package com.nonid.mixin.client; + +import com.nonid.NeedsOfNatureClient; +import net.minecraft.client.render.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +@Mixin(GameRenderer.class) +public class GameRendererReactiveImpactFovMixin { + @ModifyArg( + method = "renderWorld", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/GameRenderer;getBasicProjectionMatrix(F)Lorg/joml/Matrix4f;", ordinal = 0), + index = 0 + ) + private float non$applyReactiveImpactWorldFov(float fov) { + float offset = NeedsOfNatureClient.getReactiveImpactFovOffset(0.0F); + return Math.abs(offset) <= 1.0E-4F ? fov : Math.max(5.0F, fov + offset); + } +} diff --git a/src/main/java/com/nonid/mixin/client/HandledScreenAccessor.java b/src/main/java/com/nonid/mixin/client/HandledScreenAccessor.java new file mode 100644 index 0000000..677fd0f --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/HandledScreenAccessor.java @@ -0,0 +1,14 @@ +package com.nonid.mixin.client; + +import net.minecraft.client.gui.screen.ingame.HandledScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(HandledScreen.class) +public interface HandledScreenAccessor { + @Accessor("x") + int non$getLeft(); + + @Accessor("y") + int non$getTop(); +} diff --git a/src/main/java/com/nonid/mixin/client/HandledScreenTrinketSlotStateMixin.java b/src/main/java/com/nonid/mixin/client/HandledScreenTrinketSlotStateMixin.java new file mode 100644 index 0000000..d866486 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/HandledScreenTrinketSlotStateMixin.java @@ -0,0 +1,60 @@ +package com.nonid.mixin.client; + +import com.nonid.NonTrinketsIntegration; +import dev.emi.trinkets.TrinketSlot; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ingame.HandledScreen; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.screen.ScreenHandler; +import net.minecraft.screen.slot.Slot; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(HandledScreen.class) +public abstract class HandledScreenTrinketSlotStateMixin { + @Shadow + protected Slot focusedSlot; + + @Inject(method = "drawSlot", at = @At("TAIL")) + private void non$drawUnavailableTrinketSlotOverlay(DrawContext context, Slot slot, int mouseX, int mouseY, CallbackInfo ci) { + if (non$unavailableSlotTooltip(slot) != null) { + context.fill(slot.x, slot.y, slot.x + 16, slot.y + 16, -1728053248); + context.drawStrokedRectangle(slot.x, slot.y, 16, 16, -1435011209); + } + } + + @Inject(method = "drawMouseoverTooltip", at = @At("RETURN")) + private void non$drawUnavailableTrinketSlotTooltip(DrawContext context, int x, int y, CallbackInfo ci) { + Text tooltip = non$unavailableSlotTooltip(this.focusedSlot); + if (tooltip != null) { + context.drawTooltip(tooltip, x, y); + } + } + + private static Text non$unavailableSlotTooltip(Slot slot) { + if (slot instanceof TrinketSlot trinketSlot) { + ClientPlayerEntity player = MinecraftClient.getInstance().player; + if (player != null && trinketSlot.getType() != null) { + String slotId = trinketSlot.getType().getId(); + if (NonTrinketsIntegration.isSlotAvailableFor(player, slotId)) { + return null; + } else if ("legs/v".equals(slotId)) { + return Text.translatable("gui.needsofnature.trinket_slot.requires_female"); + } else { + return "legs/d".equals(slotId) + ? Text.translatable("gui.needsofnature.trinket_slot.requires_male") + : Text.translatable("gui.needsofnature.trinket_slot.unavailable"); + } + } else { + return null; + } + } else { + return null; + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/InGameHudMixin.java b/src/main/java/com/nonid/mixin/client/InGameHudMixin.java new file mode 100644 index 0000000..e605f26 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/InGameHudMixin.java @@ -0,0 +1,181 @@ +package com.nonid.mixin.client; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.nonid.client.NonEnergizedEffectTextures; +import com.nonid.client.NonFilledEffectTextures; +import com.nonid.client.NonHudOverlay; +import com.nonid.client.NonPregnantEffectTextures; +import com.nonid.effect.NonStatusEffects; +import java.util.ArrayList; +import java.util.Collection; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.hud.InGameHud; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.render.RenderTickCounter; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(InGameHud.class) +public class InGameHudMixin { + @Inject(method = "render", at = @At("TAIL")) + private void non$renderLiquidOverlay(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.isUiPreviewEnabled()) { + NonHudOverlay.renderUiPreview(context); + } else if (NonHudOverlay.shouldHideVanillaHud()) { + NonHudOverlay.renderOverlay(context); + } else { + NonHudOverlay.renderLiquidOverlay(context); + } + } + + @Inject(method = "renderMiscOverlays", at = @At("HEAD"), cancellable = true) + private void non$hideMiscOverlaysDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderCrosshair", at = @At("HEAD"), cancellable = true) + private void non$hideCrosshairDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderMainHud", at = @At("HEAD"), cancellable = true) + private void non$hideMainHudDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderStatusEffectOverlay", at = @At("HEAD"), cancellable = true) + private void non$hideStatusEffectsDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderBossBarHud", at = @At("HEAD"), cancellable = true) + private void non$hideBossBarsDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderSleepOverlay", at = @At("HEAD"), cancellable = true) + private void non$hideSleepOverlayDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderDemoTimer", at = @At("HEAD"), cancellable = true) + private void non$hideDemoTimerDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject( + method = "renderScoreboardSidebar(Lnet/minecraft/client/gui/DrawContext;Lnet/minecraft/client/render/RenderTickCounter;)V", + at = @At("HEAD"), + cancellable = true + ) + private void non$hideScoreboardDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderOverlayMessage", at = @At("HEAD"), cancellable = true) + private void non$hideOverlayMessageDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderTitleAndSubtitle", at = @At("HEAD"), cancellable = true) + private void non$hideTitleDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderChat", at = @At("HEAD"), cancellable = true) + private void non$hideChatDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderPlayerList", at = @At("HEAD"), cancellable = true) + private void non$hidePlayerListDuringAnimation(DrawContext context, RenderTickCounter tickCounter, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Inject(method = "renderSubtitlesHud", at = @At("HEAD"), cancellable = true) + private void non$hideSubtitlesDuringAnimation(DrawContext context, boolean defer, CallbackInfo ci) { + if (NonHudOverlay.shouldHideVanillaHud()) { + ci.cancel(); + } + } + + @Redirect( + method = "renderStatusEffectOverlay", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/network/ClientPlayerEntity;getStatusEffects()Ljava/util/Collection;") + ) + private Collection non$hideEnergizedFromHudOverlay(ClientPlayerEntity player) { + Collection effects = player.getStatusEffects(); + if (effects.isEmpty()) { + return effects; + } + + ArrayList filtered = new ArrayList<>(effects.size()); + + for (StatusEffectInstance effect : effects) { + if (effect.getEffectType().value() != NonStatusEffects.ENERGIZED) { + filtered.add(effect); + } + } + + return filtered; + } + + @Redirect( + method = "renderStatusEffectOverlay", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/DrawContext;drawGuiTexture(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/util/Identifier;IIIII)V" + ) + ) + private void non$drawFilledStatusEffectIcon(DrawContext context, RenderPipeline pipeline, Identifier sprite, int x, int y, int width, int height, int color) { + if (NonFilledEffectTextures.isFilledSprite(sprite)) { + Identifier texture = NonFilledEffectTextures.resolveTexture(MinecraftClient.getInstance().player); + context.drawTexture(RenderPipelines.GUI_TEXTURED, texture, x, y, 0.0F, 0.0F, width, height, width, height, color); + } else if (NonEnergizedEffectTextures.isEnergizedSprite(sprite)) { + Identifier texture = NonEnergizedEffectTextures.resolveTexture(MinecraftClient.getInstance().player); + context.drawTexture(RenderPipelines.GUI_TEXTURED, texture, x, y, 0.0F, 0.0F, width, height, width, height, color); + } else { + if (NonPregnantEffectTextures.shouldDrawSourceEgg(sprite)) { + ItemStack eggStack = NonPregnantEffectTextures.resolveSourceEgg(MinecraftClient.getInstance().player); + if (!eggStack.isEmpty()) { + NonPregnantEffectTextures.drawSourceEgg(context, eggStack, x, y, width, height); + return; + } + } + + context.drawGuiTexture(pipeline, sprite, x, y, width, height, color); + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/LivingEntityRendererDebugLabelMixin.java b/src/main/java/com/nonid/mixin/client/LivingEntityRendererDebugLabelMixin.java new file mode 100644 index 0000000..8d4a7d2 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/LivingEntityRendererDebugLabelMixin.java @@ -0,0 +1,19 @@ +package com.nonid.mixin.client; + +import com.nonid.client.NonDebugNameHelper; +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LivingEntityRenderer.class) +public abstract class LivingEntityRendererDebugLabelMixin { + @Inject(method = "hasLabel(Lnet/minecraft/entity/LivingEntity;D)Z", at = @At("HEAD"), cancellable = true) + private void needsOfNature$forceDebugLabel(LivingEntity entity, double distance, CallbackInfoReturnable cir) { + if (NonDebugNameHelper.shouldShowDebugLabel(entity)) { + cir.setReturnValue(true); + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/LivingEntityRendererPlayerTextureMixin.java b/src/main/java/com/nonid/mixin/client/LivingEntityRendererPlayerTextureMixin.java new file mode 100644 index 0000000..b1b0136 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/LivingEntityRendererPlayerTextureMixin.java @@ -0,0 +1,34 @@ +package com.nonid.mixin.client; + +import com.nonid.client.NonDebugSpinMode; +import com.nonid.client.NonDestroyedSkinClient; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.client.render.entity.state.LivingEntityRenderState; +import net.minecraft.client.render.entity.state.PlayerEntityRenderState; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(LivingEntityRenderer.class) +public abstract class LivingEntityRendererPlayerTextureMixin { + @ModifyVariable(method = "getRenderLayer", at = @At("STORE"), ordinal = 0) + private Identifier non$applyPlayerSkinEffectsToFinalTexture( + Identifier texture, LivingEntityRenderState state, boolean showBody, boolean translucent, boolean showOutline + ) { + if (!NonDebugSpinMode.isEnabled() && state instanceof PlayerEntityRenderState playerState) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.world != null) { + return client.world.getEntityById(playerState.id) instanceof LivingEntity living + ? NonDestroyedSkinClient.resolveRenderedTexture(living, texture) + : texture; + } else { + return texture; + } + } else { + return texture; + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/PlayerEntityRendererDestroyedSkinMixin.java b/src/main/java/com/nonid/mixin/client/PlayerEntityRendererDestroyedSkinMixin.java new file mode 100644 index 0000000..ea92e7a --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/PlayerEntityRendererDestroyedSkinMixin.java @@ -0,0 +1,51 @@ +package com.nonid.mixin.client; + +import com.nonid.client.NonDebugSpinMode; +import com.nonid.client.NonDestroyedSkinClient; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.entity.PlayerEntityRenderer; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +@Mixin(PlayerEntityRenderer.class) +public abstract class PlayerEntityRendererDestroyedSkinMixin { + @ModifyArg( + method = "renderRightArm", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/render/entity/PlayerEntityRenderer;renderArm(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/command/OrderedRenderCommandQueue;ILnet/minecraft/util/Identifier;Lnet/minecraft/client/model/ModelPart;Z)V" + ), + index = 3 + ) + private Identifier non$applyDestroyedSkinToRightArm(Identifier skinTexture) { + return non$resolveLocalHandTexture(skinTexture); + } + + @ModifyArg( + method = "renderLeftArm", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/render/entity/PlayerEntityRenderer;renderArm(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/render/command/OrderedRenderCommandQueue;ILnet/minecraft/util/Identifier;Lnet/minecraft/client/model/ModelPart;Z)V" + ), + index = 3 + ) + private Identifier non$applyDestroyedSkinToLeftArm(Identifier skinTexture) { + return non$resolveLocalHandTexture(skinTexture); + } + + private static Identifier non$resolveLocalHandTexture(Identifier skinTexture) { + if (NonDebugSpinMode.isEnabled()) { + return skinTexture; + } else { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + Identifier replacement = NonDestroyedSkinClient.resolveBaseTexture(client.player, skinTexture); + return replacement != null ? replacement : skinTexture; + } else { + return skinTexture; + } + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/ResourcePackOrganizerHideNoNExternalMixin.java b/src/main/java/com/nonid/mixin/client/ResourcePackOrganizerHideNoNExternalMixin.java new file mode 100644 index 0000000..d1e0549 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/ResourcePackOrganizerHideNoNExternalMixin.java @@ -0,0 +1,48 @@ +package com.nonid.mixin.client; + +import java.util.List; +import net.minecraft.client.gui.screen.pack.ResourcePackOrganizer; +import net.minecraft.resource.ResourcePackProfile; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ResourcePackOrganizer.class) +public abstract class ResourcePackOrganizerHideNoNExternalMixin { + @Shadow + @Final + private List enabledPacks; + @Shadow + @Final + private List disabledPacks; + + @Inject(method = "", at = @At("RETURN")) + private void non$hideExternalNoNPacksOnInit(CallbackInfo ci) { + this.non$filterExternalNoNPacks(); + } + + @Inject(method = "refresh", at = @At("RETURN")) + private void non$hideExternalNoNPacksOnRefresh(CallbackInfo ci) { + this.non$filterExternalNoNPacks(); + } + + @Unique + private void non$filterExternalNoNPacks() { + this.enabledPacks.removeIf(ResourcePackOrganizerHideNoNExternalMixin::non$isNoNExternalPackProfile); + this.disabledPacks.removeIf(ResourcePackOrganizerHideNoNExternalMixin::non$isNoNExternalPackProfile); + } + + @Unique + private static boolean non$isNoNExternalPackProfile(ResourcePackProfile profile) { + if (profile == null) { + return false; + } + + String id = profile.getId(); + return id != null && !id.isBlank() ? id.startsWith("needsofnature/client/") || id.startsWith("needsofnature/server/") : false; + } +} diff --git a/src/main/java/com/nonid/mixin/client/StatusEffectsDisplayMixin.java b/src/main/java/com/nonid/mixin/client/StatusEffectsDisplayMixin.java new file mode 100644 index 0000000..bdaec6c --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/StatusEffectsDisplayMixin.java @@ -0,0 +1,45 @@ +package com.nonid.mixin.client; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.nonid.client.NonEnergizedEffectTextures; +import com.nonid.client.NonFilledEffectTextures; +import com.nonid.client.NonPregnantEffectTextures; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ingame.StatusEffectsDisplay; +import net.minecraft.item.ItemStack; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(StatusEffectsDisplay.class) +public class StatusEffectsDisplayMixin { + @Redirect( + method = "drawStatusEffects", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/DrawContext;drawGuiTexture(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/util/Identifier;IIII)V" + ) + ) + private void non$drawFilledEffectSprite(DrawContext context, RenderPipeline pipeline, Identifier sprite, int x, int y, int width, int height) { + if (NonFilledEffectTextures.isFilledSprite(sprite)) { + Identifier texture = NonFilledEffectTextures.resolveTexture(MinecraftClient.getInstance().player); + context.drawTexture(RenderPipelines.GUI_TEXTURED, texture, x, y, 0.0F, 0.0F, width, height, width, height); + } else if (NonEnergizedEffectTextures.isEnergizedSprite(sprite)) { + Identifier texture = NonEnergizedEffectTextures.resolveTexture(MinecraftClient.getInstance().player); + context.drawTexture(RenderPipelines.GUI_TEXTURED, texture, x, y, 0.0F, 0.0F, width, height, width, height); + } else { + if (NonPregnantEffectTextures.shouldDrawSourceEgg(sprite)) { + ItemStack eggStack = NonPregnantEffectTextures.resolveSourceEgg(MinecraftClient.getInstance().player); + if (!eggStack.isEmpty()) { + NonPregnantEffectTextures.drawSourceEgg(context, eggStack, x, y, width, height); + return; + } + } + + context.drawGuiTexture(pipeline, sprite, x, y, width, height); + } + } +} diff --git a/src/main/java/com/nonid/mixin/client/TranslationStorageStatLabelMixin.java b/src/main/java/com/nonid/mixin/client/TranslationStorageStatLabelMixin.java new file mode 100644 index 0000000..2608174 --- /dev/null +++ b/src/main/java/com/nonid/mixin/client/TranslationStorageStatLabelMixin.java @@ -0,0 +1,25 @@ +package com.nonid.mixin.client; + +import com.nonid.client.NonStatLabelResolver; +import net.minecraft.client.resource.language.TranslationStorage; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(TranslationStorage.class) +public abstract class TranslationStorageStatLabelMixin { + @Shadow + public abstract boolean hasTranslation(String var1); + + @Inject(method = "get", at = @At("HEAD"), cancellable = true) + private void non$resolveDynamicStatLabel(String key, String fallback, CallbackInfoReturnable cir) { + if (key != null && !this.hasTranslation(key)) { + String resolved = NonStatLabelResolver.resolve(key); + if (resolved != null) { + cir.setReturnValue(resolved); + } + } + } +} diff --git a/src/main/java/com/nonid/network/AccessoryShedStateS2CPayload.java b/src/main/java/com/nonid/network/AccessoryShedStateS2CPayload.java new file mode 100644 index 0000000..a895f1d --- /dev/null +++ b/src/main/java/com/nonid/network/AccessoryShedStateS2CPayload.java @@ -0,0 +1,32 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record AccessoryShedStateS2CPayload(UUID playerUuid, boolean shedV, boolean shedA, boolean forcedV, boolean forcedA) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "accessory_shed_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + AccessoryShedStateS2CPayload::playerUuid, + PacketCodecs.BOOLEAN, + AccessoryShedStateS2CPayload::shedV, + PacketCodecs.BOOLEAN, + AccessoryShedStateS2CPayload::shedA, + PacketCodecs.BOOLEAN, + AccessoryShedStateS2CPayload::forcedV, + PacketCodecs.BOOLEAN, + AccessoryShedStateS2CPayload::forcedA, + AccessoryShedStateS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/AnimationCatalogChunkS2CPayload.java b/src/main/java/com/nonid/network/AnimationCatalogChunkS2CPayload.java new file mode 100644 index 0000000..971ca8b --- /dev/null +++ b/src/main/java/com/nonid/network/AnimationCatalogChunkS2CPayload.java @@ -0,0 +1,27 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record AnimationCatalogChunkS2CPayload(long revision, int index, int count, String data) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "animation_catalog_chunk")); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.LONG, + AnimationCatalogChunkS2CPayload::revision, + PacketCodecs.VAR_INT, + AnimationCatalogChunkS2CPayload::index, + PacketCodecs.VAR_INT, + AnimationCatalogChunkS2CPayload::count, + PacketCodecs.STRING, + AnimationCatalogChunkS2CPayload::data, + AnimationCatalogChunkS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/AnimationCatalogData.java b/src/main/java/com/nonid/network/AnimationCatalogData.java new file mode 100644 index 0000000..1f134aa --- /dev/null +++ b/src/main/java/com/nonid/network/AnimationCatalogData.java @@ -0,0 +1,256 @@ +package com.nonid.network; + +import com.nonid.NonConfig; +import com.nonid.data.NonLiquidRoles; +import com.nonid.data.NonPeakStages; +import com.nonid.internal.animation.data.AnimationDefinitions; +import com.nonid.internal.animation.data.AnimationGameplayMetadata; +import com.nonid.internal.animation.network.AnimationStageInfo; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public record AnimationCatalogData(List animations) { + public static AnimationCatalogData capture(NonConfig config) { + Set disabledAnimations = config == null ? Set.of() : Set.copyOf(config.getDisabledAnimations()); + Set disabledPacks = config == null ? Set.of() : Set.copyOf(config.getDisabledAnimationPacks()); + List entries = new ArrayList<>(); + + for (AnimationDefinitions.Definition definition : AnimationDefinitions.getLoadedDefinitionsSnapshot()) { + entries.add(fromDefinition(definition, disabledAnimations, disabledPacks)); + } + + entries.sort(Comparator.comparing(AnimationCatalogData.Entry::id)); + return new AnimationCatalogData(List.copyOf(entries)); + } + + private static AnimationCatalogData.Entry fromDefinition( + AnimationDefinitions.Definition definition, Set disabledAnimations, Set disabledPacks + ) { + List actorKeys = AnimationDefinitions.resolveActorKeys(definition); + NonLiquidRoles.LiquidRoles liquidRoles = AnimationGameplayMetadata.liquidRoles(definition.id()); + List actors = new ArrayList<>(definition.actors().size()); + + for (int i = 0; i < definition.actors().size(); i++) { + AnimationDefinitions.ActorConstraint constraint = definition.actors().get(i); + List entityTypes = constraint.entityTypes().stream().filter(Objects::nonNull).map(Identifier::toString).sorted().toList(); + List requiredTags = constraint.requiredTags().stream().sorted().toList(); + List requiredTagsAny = constraint.requiredTagsAny().stream().sorted().toList(); + String key = i < actorKeys.size() ? actorKeys.get(i) : constraint.derivedKey(); + NonLiquidRoles.InjectorRole injectorRole = liquidRoles == null + ? NonLiquidRoles.InjectorRole.NONE + : liquidRoles.injectorRoles().getOrDefault(key, NonLiquidRoles.InjectorRole.NONE); + boolean receiver = liquidRoles != null && liquidRoles.receiverKeys().contains(key); + actors.add(new AnimationCatalogData.Actor(key, entityTypes, requiredTags, requiredTagsAny, injectorRole.name(), receiver)); + } + + List stages = new ArrayList<>(); + + for (AnimationStageInfo stage : definition.stages()) { + double cycleTicks = AnimationGameplayMetadata.cycleTicks(stage.animationId()); + if (cycleTicks <= 0.0) { + cycleTicks = stage.lengthTicks(); + } + + stages.add( + new AnimationCatalogData.Stage( + stage.effectiveAnimationId().toString(), + stage.loop(), + stage.lengthTicks(), + stage.speed(), + cycleTicks, + NonPeakStages.stageNumberFromId(stage.animationId()) + ) + ); + } + + if (stages.isEmpty()) { + stages.add(new AnimationCatalogData.Stage(definition.id().toString(), false, 0L, 1.0, 0.0, null)); + } + + AnimationDefinitions.AnimationPackInfo packInfo = definition.packInfo(); + AnimationCatalogData.Pack pack = packInfo == null + ? new AnimationCatalogData.Pack("unknown", "unknown", "Unknown Pack", "Unknown", null, null, null, List.of()) + : new AnimationCatalogData.Pack( + packInfo.id(), + packInfo.resourcePackId(), + packInfo.name(), + packInfo.author(), + packInfo.version(), + packInfo.description(), + packInfo.localModelRoot() == null ? null : packInfo.localModelRoot().toString(), + packInfo.modelImports() + .stream() + .map( + modelImport -> new AnimationCatalogData.ModelImport( + modelImport.packId(), + modelImport.modelRoot().toString(), + modelImport.entityTypes().stream().map(Identifier::toString).sorted().toList() + ) + ) + .toList() + ); + String id = definition.id().toString(); + return new AnimationCatalogData.Entry( + id, + definition.displayName(), + List.copyOf(actors), + List.copyOf(stages), + NonPeakStages.getPeakStage(definition.id()), + definition.contentTags() == null ? List.of() : List.copyOf(definition.contentTags()), + definition.attackEligible(), + summarizeBlockRequirements(definition.blockRequirements()), + definition.waterRequirement() == null ? null : definition.waterRequirement().name().toLowerCase(Locale.ROOT), + pack, + disabledAnimations.contains(id), + disabledPacks.contains(pack.id()) + ); + } + + @Nullable + private static String summarizeBlockRequirements(@Nullable AnimationDefinitions.BlockRequirements requirements) { + if (requirements == null) { + return null; + } + + List parts = new ArrayList<>(); + String type = requirements.type() != null && !requirements.type().isBlank() ? requirements.type() : "unknown"; + parts.add(localizeBlockType(type)); + if (requirements.isWall()) { + AnimationDefinitions.WallHeight height = requirements.height(); + if (height != null) { + parts.add(height.max() == null ? "高度 " + height.min() + "+" : "高度 " + height.min() + "-" + height.max()); + } + + AnimationDefinitions.WallWidth width = requirements.width(); + if (width != null && (width.min() > 1 || width.max() != null)) { + parts.add(width.max() == null ? "宽度 " + width.min() + "+" : "宽度 " + width.min() + "-" + width.max()); + } + + if (requirements.facing() != null) { + parts.add("朝向 " + localizeFacing(requirements.facing().id())); + } + } else if (requirements.isCenterSupport()) { + if (requirements.support() != null) { + parts.add(requirements.support().id()); + } + + if (requirements.placement() != null) { + parts.add(requirements.placement().id()); + } + + if (requirements.surfaceFootprint() != null) { + AnimationDefinitions.SurfaceFootprint footprint = requirements.surfaceFootprint(); + parts.add("占地 " + footprint.width() + "x" + footprint.depth() + "x" + footprint.height()); + if (footprint.margin() > 0) { + parts.add("边距 " + footprint.margin()); + } + } else if (requirements.surfaceRadius() > 0) { + parts.add("表面半径 " + requirements.surfaceRadius()); + } + } + + AnimationDefinitions.Clearance clearance = requirements.clearance(); + if (clearance != null) { + parts.add("净空 " + clearance.width() + "x" + clearance.height() + "x" + clearance.depth()); + } + + AnimationDefinitions.BlockPredicate blocks = requirements.blocks(); + if (blocks != null && !blocks.isEmpty()) { + parts.add("方块 " + summarizeBlockPredicate(blocks)); + } + + return "方块: " + String.join(" | ", parts); + } + + private static String localizeBlockType(String type) { + return switch (type) { + case "wall" -> "墙"; + case "center support" -> "中心支撑"; + case "surface" -> "表面"; + default -> type.replace('_', ' '); + }; + } + + private static String localizeFacing(String facing) { + return switch (facing) { + case "north" -> "北"; + case "south" -> "南"; + case "east" -> "东"; + case "west" -> "西"; + case "up" -> "上"; + case "down" -> "下"; + default -> facing; + }; + } + + private static String summarizeBlockPredicate(AnimationDefinitions.BlockPredicate blocks) { + LinkedHashSet values = new LinkedHashSet<>(); + if (blocks.blockIds() != null) { + blocks.blockIds().stream().filter(Objects::nonNull).map(Identifier::toString).sorted().forEach(values::add); + } + + if (blocks.tagIds() != null) { + blocks.tagIds().stream().filter(Objects::nonNull).map(id -> "#" + id).sorted().forEach(values::add); + } + + if (values.isEmpty()) { + return "-"; + } + + List list = List.copyOf(values); + int take = Math.min(3, list.size()); + String summary = String.join(", ", list.subList(0, take)); + int remaining = list.size() - take; + return remaining > 0 ? summary + ", +" + remaining : summary; + } + + public record Actor(String key, List entityTypes, List requiredTags, List requiredTagsAny, String injectorType, boolean receiver) { + } + + public record Entry( + String id, + @Nullable String displayName, + List actors, + List stages, + @Nullable Integer peakStage, + List contentTags, + boolean attackEligible, + @Nullable String blockRequirement, + @Nullable String waterRequirement, + AnimationCatalogData.Pack pack, + boolean animationDisabled, + boolean packDisabled + ) { + } + + public record ModelImport(String packId, String modelRoot, List entityTypes) { + public ModelImport { + entityTypes = entityTypes == null ? List.of() : List.copyOf(entityTypes); + } + } + + public record Pack( + String id, + String resourcePackId, + String name, + String author, + @Nullable String version, + @Nullable String description, + @Nullable String localModelRoot, + List modelImports + ) { + public Pack { + modelImports = modelImports == null ? List.of() : List.copyOf(modelImports); + } + } + + public record Stage(String animationId, boolean loop, long lengthTicks, double speed, double cycleTicks, @Nullable Integer stageNumber) { + } +} diff --git a/src/main/java/com/nonid/network/AttackStateS2CPayload.java b/src/main/java/com/nonid/network/AttackStateS2CPayload.java new file mode 100644 index 0000000..15ae3a7 --- /dev/null +++ b/src/main/java/com/nonid/network/AttackStateS2CPayload.java @@ -0,0 +1,84 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record AttackStateS2CPayload( + UUID instanceId, boolean attack, boolean escapable, AttackStateS2CPayload.EscapeProfile escapeProfile, AttackStateS2CPayload.PvpRole pvpRole, float progress +) implements CustomPayload { + public static final Identifier ATTACK_STATE_ID = Identifier.of("needsofnature", "attack_state"); + public static final Id ID = new Id(ATTACK_STATE_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + AttackStateS2CPayload::instanceId, + PacketCodecs.BOOLEAN, + AttackStateS2CPayload::attack, + PacketCodecs.BOOLEAN, + AttackStateS2CPayload::escapable, + PacketCodecs.INTEGER, + payload -> payload.escapeProfile().ordinal(), + PacketCodecs.INTEGER, + payload -> payload.pvpRole().ordinal(), + PacketCodecs.FLOAT, + AttackStateS2CPayload::progress, + AttackStateS2CPayload::fromPacket + ); + + public AttackStateS2CPayload { + if (escapeProfile == null) { + escapeProfile = AttackStateS2CPayload.EscapeProfile.NONE; + } + + if (pvpRole == null) { + pvpRole = AttackStateS2CPayload.PvpRole.NONE; + } + + if (!Float.isFinite(progress)) { + progress = 0.0F; + } + + progress = Math.max(0.0F, Math.min(1.0F, progress)); + } + + public AttackStateS2CPayload(UUID instanceId, boolean attack, boolean escapable, AttackStateS2CPayload.EscapeProfile escapeProfile) { + this(instanceId, attack, escapable, escapeProfile, AttackStateS2CPayload.PvpRole.NONE, 0.0F); + } + + private static AttackStateS2CPayload fromPacket(UUID instanceId, boolean attack, boolean escapable, int profileId, int roleId, float progress) { + return new AttackStateS2CPayload( + instanceId, attack, escapable, AttackStateS2CPayload.EscapeProfile.fromId(profileId), AttackStateS2CPayload.PvpRole.fromId(roleId), progress + ); + } + + public Id getId() { + return ID; + } + + public enum EscapeProfile { + NONE, + NORMAL, + DEFEATED; + + public static AttackStateS2CPayload.EscapeProfile fromId(int id) { + AttackStateS2CPayload.EscapeProfile[] values = values(); + return id >= 0 && id < values.length ? values[id] : NONE; + } + } + + public enum PvpRole { + NONE, + DEFENDER, + ATTACKER; + + public static AttackStateS2CPayload.PvpRole fromId(int id) { + AttackStateS2CPayload.PvpRole[] values = values(); + return id >= 0 && id < values.length ? values[id] : NONE; + } + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinMaskSyncS2CPayload.java b/src/main/java/com/nonid/network/DestroyedSkinMaskSyncS2CPayload.java new file mode 100644 index 0000000..0f07912 --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinMaskSyncS2CPayload.java @@ -0,0 +1,22 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinMaskSyncS2CPayload(UUID playerUuid, int stage, byte[] pngBytes) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_mask_sync"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.ofStatic((buf, payload) -> { + buf.writeUuid(payload.playerUuid()); + buf.writeVarInt(payload.stage()); + buf.writeByteArray(payload.pngBytes()); + }, buf -> new DestroyedSkinMaskSyncS2CPayload(buf.readUuid(), buf.readVarInt(), buf.readByteArray(65536))); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinMaskUploadC2SPayload.java b/src/main/java/com/nonid/network/DestroyedSkinMaskUploadC2SPayload.java new file mode 100644 index 0000000..43326b9 --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinMaskUploadC2SPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinMaskUploadC2SPayload(int stage, byte[] pngBytes) implements CustomPayload { + public static final int MAX_BYTES = 65536; + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_mask_upload"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.VAR_INT, + DestroyedSkinMaskUploadC2SPayload::stage, + PacketCodecs.byteArray(65536), + DestroyedSkinMaskUploadC2SPayload::pngBytes, + DestroyedSkinMaskUploadC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinParticlesS2CPayload.java b/src/main/java/com/nonid/network/DestroyedSkinParticlesS2CPayload.java new file mode 100644 index 0000000..bccde23 --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinParticlesS2CPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinParticlesS2CPayload(UUID playerUuid, double x, double y, double z, int count, long seed) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_particles"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.ofStatic((buf, payload) -> { + buf.writeUuid(payload.playerUuid()); + buf.writeDouble(payload.x()); + buf.writeDouble(payload.y()); + buf.writeDouble(payload.z()); + buf.writeVarInt(payload.count()); + buf.writeLong(payload.seed()); + }, buf -> new DestroyedSkinParticlesS2CPayload(buf.readUuid(), buf.readDouble(), buf.readDouble(), buf.readDouble(), buf.readVarInt(), buf.readLong())); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinStateS2CPayload.java b/src/main/java/com/nonid/network/DestroyedSkinStateS2CPayload.java new file mode 100644 index 0000000..bbf76cd --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinStateS2CPayload.java @@ -0,0 +1,21 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinStateS2CPayload(UUID playerUuid, int stage) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.ofStatic((buf, payload) -> { + buf.writeUuid(payload.playerUuid()); + buf.writeVarInt(payload.stage()); + }, buf -> new DestroyedSkinStateS2CPayload(buf.readUuid(), buf.readVarInt())); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinSyncS2CPayload.java b/src/main/java/com/nonid/network/DestroyedSkinSyncS2CPayload.java new file mode 100644 index 0000000..67328c5 --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinSyncS2CPayload.java @@ -0,0 +1,22 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinSyncS2CPayload(UUID playerUuid, int stage, byte[] pngBytes) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_sync"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.ofStatic((buf, payload) -> { + buf.writeUuid(payload.playerUuid()); + buf.writeVarInt(payload.stage()); + buf.writeByteArray(payload.pngBytes()); + }, buf -> new DestroyedSkinSyncS2CPayload(buf.readUuid(), buf.readVarInt(), buf.readByteArray(1048576))); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/DestroyedSkinUploadC2SPayload.java b/src/main/java/com/nonid/network/DestroyedSkinUploadC2SPayload.java new file mode 100644 index 0000000..be4dade --- /dev/null +++ b/src/main/java/com/nonid/network/DestroyedSkinUploadC2SPayload.java @@ -0,0 +1,24 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record DestroyedSkinUploadC2SPayload(byte[] pngBytes) implements CustomPayload { + public static final int MIN_DIMENSION = 64; + public static final int MAX_DIMENSION = 512; + public static final int DIMENSION_STEP = 64; + public static final int MAX_BYTES = 1048576; + public static final Identifier ID_RAW = Identifier.of("needsofnature", "destroyed_skin_upload"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.byteArray(1048576), DestroyedSkinUploadC2SPayload::pngBytes, DestroyedSkinUploadC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/GenderSelectionPromptS2CPayload.java b/src/main/java/com/nonid/network/GenderSelectionPromptS2CPayload.java new file mode 100644 index 0000000..1838bea --- /dev/null +++ b/src/main/java/com/nonid/network/GenderSelectionPromptS2CPayload.java @@ -0,0 +1,26 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record GenderSelectionPromptS2CPayload(int allowedMask, int currentMask, boolean permanent) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "gender_selection_prompt"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.INTEGER, + GenderSelectionPromptS2CPayload::allowedMask, + PacketCodecs.INTEGER, + GenderSelectionPromptS2CPayload::currentMask, + PacketCodecs.BOOLEAN, + GenderSelectionPromptS2CPayload::permanent, + GenderSelectionPromptS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/HostConfigSyncData.java b/src/main/java/com/nonid/network/HostConfigSyncData.java new file mode 100644 index 0000000..ee3fda0 --- /dev/null +++ b/src/main/java/com/nonid/network/HostConfigSyncData.java @@ -0,0 +1,337 @@ +package com.nonid.network; + +import com.nonid.NonConfig; +import com.nonid.data.NonPeakStages; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class HostConfigSyncData { + public int initialEnergyMax; + public double energyGainRate; + public double nearAnimationEnergyGainMult; + public double playerEnergyAuraMultLow; + public double playerEnergyAuraMultMid; + public double playerEnergyAuraMultHigh; + public double destroyedSkinAuraMultStage1; + public double destroyedSkinAuraMultStage2; + public double destroyedSkinAuraMultStage3; + public double destroyedSkinAuraMultStage4; + public int normalDamageDestroyedSkinChancePercent = 10; + public boolean lastDefeatedEnabled = true; + public int lastDefeatedEnergyThreshold = 70; + public int lastDefeatedSearchRadius = 6; + public int lastDefeatedCooldownSeconds = 120; + public int playerEnergyAuraRadiusMin; + public int playerEnergyAuraRadiusMax; + public int playerEnergyAuraPulseTicks; + public int scanBudgetPerTick; + public int animationBlockSearchRadius = 3; + public int loopProgressSeconds; + public int peakLoopProgressSeconds; + public int genderMaleChancePercent; + public int genderFemaleChancePercent; + public int genderBothChancePercent; + public int maleMaleChancePercent; + public int femaleFemaleChancePercent; + public int entityEntityChancePercent; + public int multiActorJoinChancePercent; + public boolean allowPlayerGenderChangeAnytime = true; + public boolean requirePlayerGenderSelectionOnJoin = false; + public int allowedStartingGenderMask = 7; + public int allowedPlayerInteractionProfilesMask = 15; + public int playerRequestMinEnergy = 70; + public boolean pregnancyEnabled; + public boolean liquidTankEnabled; + public boolean messSystemEnabled = true; + public boolean destroyedSkinSystemEnabled = true; + public boolean allowCraftingTableSkinRepair = false; + public boolean keepMessAfterDeath = false; + public boolean keepRippedSkinAfterDeath = false; + public boolean keepLiquidTankAfterDeath = false; + public boolean keepPregnancyAfterDeath = false; + public int liquidTankCapacityMl = 200; + public boolean vanillaOverridesEnabled; + public boolean useAnimationBreeding; + public boolean requireMaleFemaleForBreeding; + public boolean femaleCowOnlyMilking; + public boolean attackCreativePlayers; + public boolean convertMaleOnlyVInjectionsToA = true; + public int attackEscapeHits; + public double attackDecayPerSecond; + public int attackEscapeDamageDifficultyPercent = 100; + public int pvpFullHealthEscapeBonusPercent = 30; + public int pvpCriticalHealthMashStrengthPercent = 25; + public int pvpMaxHeatEscapeStrengthPercent = 70; + public int pvpZeroHeatAttackStrengthPercent = 70; + public int attackEscapeInvulnerabilitySeconds = 3; + public int attackEscapeAnimationProtectionSeconds = 3; + public int postEscapeGatherMaxMobs = 8; + public boolean blockAnimationsWhileRidingLivingEntities = true; + public boolean blockAnimationsWhileRidingVehicles = false; + public int attackOutcomeFailsafeThreshold = 3; + public int pregnancyChancePercent; + public int pregnancyDurationMinutes; + public boolean liquidBasedPregnancy = false; + public int pregnancyRiskyStartDay = 13; + public int pregnancyOvulationEndDay = 17; + public int pregnancySafeDayChancePercent = 10; + public int pregnancyRiskyChancePercent = 300; + public int pregnancyOvulationChancePercent = 500; + public Map pregnancyChanceByEntity = new LinkedHashMap<>(); + public Map pregnancyOffspringCountByEntity = new LinkedHashMap<>(); + public int pregnancyEggDefaultHatchSeconds; + public Map pregnancyEggEnabledByEntity = new LinkedHashMap<>(); + public Map pregnancyEggHatchSecondsByEntity = new LinkedHashMap<>(); + public boolean pregnancyAutoTameMobs = true; + public boolean pregnancyFriendlyMobsIgnorePlayers = false; + public Map entityProfilesByEntity = new LinkedHashMap<>(); + public double liquidDecayPerSecond; + public double peakXpPerMl = 0.06666666666666667; + public float filledStageOneSpeedMult; + public float filledStageTwoSpeedMult; + public float filledStageThreeSpeedMult; + public float filledStageOneJumpMult; + public float filledStageTwoJumpMult; + public float filledStageThreeJumpMult; + public Map genderSpawnByEntity = new LinkedHashMap<>(); + public Map liquidGainByEntity = new LinkedHashMap<>(); + public Map liquidColorByEntity = new LinkedHashMap<>(); + public List disabledAnimationPacks = List.of(); + public List disabledAnimations = List.of(); + public Map peakStageByAnimation = new LinkedHashMap<>(); + + public static HostConfigSyncData fromConfig(NonConfig config) { + HostConfigSyncData data = new HostConfigSyncData(); + if (config == null) { + return data; + } + + data.initialEnergyMax = config.getInitialEnergyMax(); + data.energyGainRate = config.getEnergyGainRate(); + data.nearAnimationEnergyGainMult = config.getNearAnimationEnergyGainMult(); + data.playerEnergyAuraMultLow = config.getPlayerEnergyAuraMultLow(); + data.playerEnergyAuraMultMid = config.getPlayerEnergyAuraMultMid(); + data.playerEnergyAuraMultHigh = config.getPlayerEnergyAuraMultHigh(); + data.destroyedSkinAuraMultStage1 = config.getDestroyedSkinAuraMultStage1(); + data.destroyedSkinAuraMultStage2 = config.getDestroyedSkinAuraMultStage2(); + data.destroyedSkinAuraMultStage3 = config.getDestroyedSkinAuraMultStage3(); + data.destroyedSkinAuraMultStage4 = config.getDestroyedSkinAuraMultStage4(); + data.normalDamageDestroyedSkinChancePercent = config.getNormalDamageDestroyedSkinChancePercent(); + data.lastDefeatedEnabled = config.isLastDefeatedEnabled(); + data.lastDefeatedEnergyThreshold = config.getLastDefeatedEnergyThreshold(); + data.lastDefeatedSearchRadius = config.getLastDefeatedSearchRadius(); + data.lastDefeatedCooldownSeconds = config.getLastDefeatedCooldownSeconds(); + data.playerEnergyAuraRadiusMin = config.getPlayerEnergyAuraRadiusMin(); + data.playerEnergyAuraRadiusMax = config.getPlayerEnergyAuraRadiusMax(); + data.playerEnergyAuraPulseTicks = config.getPlayerEnergyAuraPulseTicks(); + data.scanBudgetPerTick = config.getScanBudgetPerTick(); + data.animationBlockSearchRadius = config.blockSearchRadius(); + data.loopProgressSeconds = config.getLoopProgressSeconds(); + data.peakLoopProgressSeconds = config.getPeakLoopProgressSeconds(); + data.genderMaleChancePercent = config.getGenderMaleChancePercent(); + data.genderFemaleChancePercent = config.getGenderFemaleChancePercent(); + data.genderBothChancePercent = config.getGenderBothChancePercent(); + data.maleMaleChancePercent = config.getMaleMaleChancePercent(); + data.femaleFemaleChancePercent = config.getFemaleFemaleChancePercent(); + data.entityEntityChancePercent = config.getEntityEntityChancePercent(); + data.multiActorJoinChancePercent = config.getMultiActorJoinChancePercent(); + data.allowPlayerGenderChangeAnytime = config.allowPlayerGenderChangeAnytime(); + data.requirePlayerGenderSelectionOnJoin = config.requirePlayerGenderSelectionOnJoin(); + data.allowedStartingGenderMask = config.getAllowedStartingGenderMask(); + data.allowedPlayerInteractionProfilesMask = config.getAllowedPlayerInteractionProfilesMask(); + data.playerRequestMinEnergy = config.getPlayerRequestMinEnergy(); + data.pregnancyEnabled = config.isPregnancyEnabled(); + data.liquidTankEnabled = config.isLiquidTankEnabled(); + data.messSystemEnabled = config.isMessSystemEnabled(); + data.destroyedSkinSystemEnabled = config.isDestroyedSkinSystemEnabled(); + data.allowCraftingTableSkinRepair = config.isCraftingTableSkinRepairAllowed(); + data.keepMessAfterDeath = config.keepMessAfterDeath(); + data.keepRippedSkinAfterDeath = config.keepRippedSkinAfterDeath(); + data.keepLiquidTankAfterDeath = config.keepLiquidTankAfterDeath(); + data.keepPregnancyAfterDeath = config.keepPregnancyAfterDeath(); + data.liquidTankCapacityMl = config.getLiquidTankCapacityMl(); + data.vanillaOverridesEnabled = config.isVanillaOverridesEnabled(); + data.useAnimationBreeding = config.useAnimationBreeding(); + data.requireMaleFemaleForBreeding = config.requireMaleFemaleForBreeding(); + data.femaleCowOnlyMilking = config.femaleCowOnlyMilking(); + data.attackCreativePlayers = config.canAttackCreativePlayers(); + data.convertMaleOnlyVInjectionsToA = config.convertMaleOnlyVInjectionsToA(); + data.attackEscapeHits = config.getAttackEscapeHits(); + data.attackDecayPerSecond = config.getAttackDecayPerSecond(); + data.attackEscapeDamageDifficultyPercent = config.getAttackEscapeDamageDifficultyPercent(); + data.pvpFullHealthEscapeBonusPercent = config.getPvpFullHealthEscapeBonusPercent(); + data.pvpCriticalHealthMashStrengthPercent = config.getPvpCriticalHealthMashStrengthPercent(); + data.pvpMaxHeatEscapeStrengthPercent = config.getPvpMaxHeatEscapeStrengthPercent(); + data.pvpZeroHeatAttackStrengthPercent = config.getPvpZeroHeatAttackStrengthPercent(); + data.attackEscapeInvulnerabilitySeconds = config.getAttackEscapeInvulnerabilitySeconds(); + data.attackEscapeAnimationProtectionSeconds = config.getAttackEscapeAnimationProtectionSeconds(); + data.postEscapeGatherMaxMobs = config.getPostEscapeGatherMaxMobs(); + data.blockAnimationsWhileRidingLivingEntities = config.blockAnimationsWhileRidingLivingEntities(); + data.blockAnimationsWhileRidingVehicles = config.blockAnimationsWhileRidingVehicles(); + data.attackOutcomeFailsafeThreshold = config.getAttackOutcomeFailsafeThreshold(); + data.pregnancyChancePercent = config.getPregnancyChancePercent(); + data.pregnancyDurationMinutes = config.getPregnancyDurationMinutes(); + data.liquidBasedPregnancy = config.isLiquidBasedPregnancyEnabled(); + data.pregnancyRiskyStartDay = config.getPregnancyRiskyStartDay(); + data.pregnancyOvulationEndDay = config.getPregnancyOvulationEndDay(); + data.pregnancySafeDayChancePercent = config.getPregnancySafeDayChancePercent(); + data.pregnancyRiskyChancePercent = config.getPregnancyRiskyChancePercent(); + data.pregnancyOvulationChancePercent = config.getPregnancyOvulationChancePercent(); + data.pregnancyChanceByEntity = new LinkedHashMap<>(config.getPregnancyChanceByEntity()); + data.pregnancyOffspringCountByEntity = new LinkedHashMap<>(config.getPregnancyOffspringCountByEntity()); + data.pregnancyEggDefaultHatchSeconds = config.getPregnancyEggDefaultHatchSeconds(); + data.pregnancyEggEnabledByEntity = new LinkedHashMap<>(config.getPregnancyEggEnabledByEntity()); + data.pregnancyEggHatchSecondsByEntity = new LinkedHashMap<>(config.getPregnancyEggHatchSecondsByEntity()); + data.pregnancyAutoTameMobs = config.isPregnancyAutoTameMobs(); + data.pregnancyFriendlyMobsIgnorePlayers = config.pregnancyFriendlyMobsIgnorePlayers(); + data.entityProfilesByEntity = new LinkedHashMap<>(config.getEntityProfilesByEntity()); + data.liquidDecayPerSecond = config.getLiquidDecayPerSecond(); + data.peakXpPerMl = config.getPeakXpPerMl(); + data.filledStageOneSpeedMult = config.getFilledStageOneSpeedMult(); + data.filledStageTwoSpeedMult = config.getFilledStageTwoSpeedMult(); + data.filledStageThreeSpeedMult = config.getFilledStageThreeSpeedMult(); + data.filledStageOneJumpMult = config.getFilledStageOneJumpMult(); + data.filledStageTwoJumpMult = config.getFilledStageTwoJumpMult(); + data.filledStageThreeJumpMult = config.getFilledStageThreeJumpMult(); + data.genderSpawnByEntity = new LinkedHashMap<>(config.getGenderSpawnByEntity()); + data.liquidGainByEntity = new LinkedHashMap<>(config.getLiquidGainByEntity()); + data.liquidColorByEntity = new LinkedHashMap<>(config.getLiquidColorByEntity()); + data.disabledAnimationPacks = List.copyOf(config.getDisabledAnimationPacks()); + data.disabledAnimations = List.copyOf(config.getDisabledAnimations()); + data.peakStageByAnimation = new LinkedHashMap<>(NonPeakStages.snapshotPeakStagesForSync()); + return data; + } + + public static HostConfigSyncData fromEditableConfig(NonConfig config) { + HostConfigSyncData data = fromConfig(config); + data.peakStageByAnimation = new LinkedHashMap<>(); + return data; + } + + public void applyTo(NonConfig config) { + this.applyEditableTo(config); + NonPeakStages.applySyncedPeakStages(copyIntMap(this.peakStageByAnimation)); + } + + public void applyEditableTo(NonConfig config) { + if (config != null) { + config.setInitialEnergyMax(this.initialEnergyMax); + config.setEnergyGainRate(this.energyGainRate); + config.setNearAnimationEnergyGainMult(this.nearAnimationEnergyGainMult); + config.setPlayerEnergyAuraMultLow(this.playerEnergyAuraMultLow); + config.setPlayerEnergyAuraMultMid(this.playerEnergyAuraMultMid); + config.setPlayerEnergyAuraMultHigh(this.playerEnergyAuraMultHigh); + config.setDestroyedSkinAuraMultStage1(this.destroyedSkinAuraMultStage1); + config.setDestroyedSkinAuraMultStage2(this.destroyedSkinAuraMultStage2); + config.setDestroyedSkinAuraMultStage3(this.destroyedSkinAuraMultStage3); + config.setDestroyedSkinAuraMultStage4(this.destroyedSkinAuraMultStage4); + config.setNormalDamageDestroyedSkinChancePercent(this.normalDamageDestroyedSkinChancePercent); + config.setLastDefeatedEnabled(this.lastDefeatedEnabled); + config.setLastDefeatedEnergyThreshold(this.lastDefeatedEnergyThreshold); + config.setLastDefeatedSearchRadius(this.lastDefeatedSearchRadius); + config.setLastDefeatedCooldownSeconds(this.lastDefeatedCooldownSeconds); + config.setPlayerEnergyAuraRadiusMin(this.playerEnergyAuraRadiusMin); + config.setPlayerEnergyAuraRadiusMax(this.playerEnergyAuraRadiusMax); + config.setPlayerEnergyAuraPulseTicks(this.playerEnergyAuraPulseTicks); + config.setScanBudgetPerTick(this.scanBudgetPerTick); + config.setBlockSearchRadius(this.animationBlockSearchRadius); + config.setLoopProgressSeconds(this.loopProgressSeconds); + config.setPeakLoopProgressSeconds(this.peakLoopProgressSeconds); + config.setGenderMaleChancePercent(this.genderMaleChancePercent); + config.setGenderFemaleChancePercent(this.genderFemaleChancePercent); + config.setGenderBothChancePercent(this.genderBothChancePercent); + config.setMaleMaleChancePercent(this.maleMaleChancePercent); + config.setFemaleFemaleChancePercent(this.femaleFemaleChancePercent); + config.setEntityEntityChancePercent(this.entityEntityChancePercent); + config.setMultiActorJoinChancePercent(this.multiActorJoinChancePercent); + config.setAllowPlayerGenderChangeAnytime(this.allowPlayerGenderChangeAnytime); + config.setRequirePlayerGenderSelectionOnJoin(this.requirePlayerGenderSelectionOnJoin); + config.setAllowedStartingGenderMask(this.allowedStartingGenderMask); + config.setAllowedPlayerInteractionProfilesMask(this.allowedPlayerInteractionProfilesMask); + config.setPlayerRequestMinEnergy(this.playerRequestMinEnergy); + config.setPregnancyEnabled(this.pregnancyEnabled); + config.setLiquidTankEnabled(this.liquidTankEnabled); + config.setMessSystemEnabled(this.messSystemEnabled); + config.setDestroyedSkinSystemEnabled(this.destroyedSkinSystemEnabled); + config.setCraftingTableSkinRepairAllowed(this.allowCraftingTableSkinRepair); + config.setKeepMessAfterDeath(this.keepMessAfterDeath); + config.setKeepRippedSkinAfterDeath(this.keepRippedSkinAfterDeath); + config.setKeepLiquidTankAfterDeath(this.keepLiquidTankAfterDeath); + config.setKeepPregnancyAfterDeath(this.keepPregnancyAfterDeath); + config.setLiquidTankCapacityMl(this.liquidTankCapacityMl); + config.setVanillaOverridesEnabled(this.vanillaOverridesEnabled); + config.setUseAnimationBreeding(this.useAnimationBreeding); + config.setRequireMaleFemaleForBreeding(this.requireMaleFemaleForBreeding); + config.setFemaleCowOnlyMilking(this.femaleCowOnlyMilking); + config.setAttackCreativePlayers(this.attackCreativePlayers); + config.setConvertMaleOnlyVInjectionsToA(this.convertMaleOnlyVInjectionsToA); + config.setAttackEscapeHits(this.attackEscapeHits); + config.setAttackDecayPerSecond(this.attackDecayPerSecond); + config.setAttackEscapeDamageDifficultyPercent(this.attackEscapeDamageDifficultyPercent); + config.setPvpFullHealthEscapeBonusPercent(this.pvpFullHealthEscapeBonusPercent); + config.setPvpCriticalHealthMashStrengthPercent(this.pvpCriticalHealthMashStrengthPercent); + config.setPvpMaxHeatEscapeStrengthPercent(this.pvpMaxHeatEscapeStrengthPercent); + config.setPvpZeroHeatAttackStrengthPercent(this.pvpZeroHeatAttackStrengthPercent); + config.setAttackEscapeInvulnerabilitySeconds(this.attackEscapeInvulnerabilitySeconds); + config.setAttackEscapeAnimationProtectionSeconds(this.attackEscapeAnimationProtectionSeconds); + config.setPostEscapeGatherMaxMobs(this.postEscapeGatherMaxMobs); + config.setBlockAnimationsWhileRidingLivingEntities(this.blockAnimationsWhileRidingLivingEntities); + config.setBlockAnimationsWhileRidingVehicles(this.blockAnimationsWhileRidingVehicles); + config.setAttackOutcomeFailsafeThreshold(this.attackOutcomeFailsafeThreshold); + config.setPregnancyChancePercent(this.pregnancyChancePercent); + config.setPregnancyDurationMinutes(this.pregnancyDurationMinutes); + config.setLiquidBasedPregnancyEnabled(this.liquidBasedPregnancy); + config.setPregnancyRiskyStartDay(this.pregnancyRiskyStartDay); + config.setPregnancyOvulationEndDay(this.pregnancyOvulationEndDay); + config.setPregnancySafeDayChancePercent(this.pregnancySafeDayChancePercent); + config.setPregnancyRiskyChancePercent(this.pregnancyRiskyChancePercent); + config.setPregnancyOvulationChancePercent(this.pregnancyOvulationChancePercent); + config.setPregnancyChanceByEntity(copyIntMap(this.pregnancyChanceByEntity)); + config.setPregnancyOffspringCountByEntity(copyOffspringCountMap(this.pregnancyOffspringCountByEntity)); + config.setPregnancyEggDefaultHatchSeconds(this.pregnancyEggDefaultHatchSeconds); + config.setPregnancyEggEnabledByEntity(copyBoolMap(this.pregnancyEggEnabledByEntity)); + config.setPregnancyEggHatchSecondsByEntity(copyIntMap(this.pregnancyEggHatchSecondsByEntity)); + config.setPregnancyAutoTameMobs(this.pregnancyAutoTameMobs); + config.setPregnancyFriendlyMobsIgnorePlayers(this.pregnancyFriendlyMobsIgnorePlayers); + config.setEntityProfilesByEntity(copyEntityProfileMap(this.entityProfilesByEntity)); + config.setLiquidDecayPerSecond(this.liquidDecayPerSecond); + config.setPeakXpPerMl(this.peakXpPerMl); + config.setFilledStageOneSpeedMult(this.filledStageOneSpeedMult); + config.setFilledStageTwoSpeedMult(this.filledStageTwoSpeedMult); + config.setFilledStageThreeSpeedMult(this.filledStageThreeSpeedMult); + config.setFilledStageOneJumpMult(this.filledStageOneJumpMult); + config.setFilledStageTwoJumpMult(this.filledStageTwoJumpMult); + config.setFilledStageThreeJumpMult(this.filledStageThreeJumpMult); + config.setGenderSpawnByEntity(copyGenderSpawnMap(this.genderSpawnByEntity)); + config.setLiquidGainByEntity(copyIntMap(this.liquidGainByEntity)); + config.setLiquidColorByEntity(copyStringMap(this.liquidColorByEntity)); + config.setDisabledAnimationPacks(this.disabledAnimationPacks == null ? List.of() : this.disabledAnimationPacks); + config.setDisabledAnimations(this.disabledAnimations == null ? List.of() : this.disabledAnimations); + } + } + + private static Map copyGenderSpawnMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } + + private static Map copyEntityProfileMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } + + private static Map copyOffspringCountMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } + + private static Map copyIntMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } + + private static Map copyBoolMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } + + private static Map copyStringMap(Map source) { + return source != null && !source.isEmpty() ? new LinkedHashMap<>(source) : Map.of(); + } +} diff --git a/src/main/java/com/nonid/network/HostConfigSyncS2CPayload.java b/src/main/java/com/nonid/network/HostConfigSyncS2CPayload.java new file mode 100644 index 0000000..f1c449b --- /dev/null +++ b/src/main/java/com/nonid/network/HostConfigSyncS2CPayload.java @@ -0,0 +1,28 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record HostConfigSyncS2CPayload(String json, String editableJson, long revision, boolean canEdit) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "host_config_sync"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.string(262144), + HostConfigSyncS2CPayload::json, + PacketCodecs.string(262144), + HostConfigSyncS2CPayload::editableJson, + PacketCodecs.LONG, + HostConfigSyncS2CPayload::revision, + PacketCodecs.BOOLEAN, + HostConfigSyncS2CPayload::canEdit, + HostConfigSyncS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/LiquidStateS2CPayload.java b/src/main/java/com/nonid/network/LiquidStateS2CPayload.java new file mode 100644 index 0000000..4f95366 --- /dev/null +++ b/src/main/java/com/nonid/network/LiquidStateS2CPayload.java @@ -0,0 +1,26 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record LiquidStateS2CPayload(int stored, int capacity, int tintRgb) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "liquid_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.INTEGER, + LiquidStateS2CPayload::stored, + PacketCodecs.INTEGER, + LiquidStateS2CPayload::capacity, + PacketCodecs.INTEGER, + LiquidStateS2CPayload::tintRgb, + LiquidStateS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/ManualDamageDestroyedSkinC2SPayload.java b/src/main/java/com/nonid/network/ManualDamageDestroyedSkinC2SPayload.java new file mode 100644 index 0000000..90e05df --- /dev/null +++ b/src/main/java/com/nonid/network/ManualDamageDestroyedSkinC2SPayload.java @@ -0,0 +1,17 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record ManualDamageDestroyedSkinC2SPayload() implements CustomPayload { + public static final Identifier MANUAL_DAMAGE_DESTROYED_SKIN_ID = Identifier.of("needsofnature", "manual_damage_destroyed_skin"); + public static final Id ID = new Id(MANUAL_DAMAGE_DESTROYED_SKIN_ID); + public static final PacketCodec CODEC = PacketCodec.unit(new ManualDamageDestroyedSkinC2SPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/MessStateS2CPayload.java b/src/main/java/com/nonid/network/MessStateS2CPayload.java new file mode 100644 index 0000000..647e7a8 --- /dev/null +++ b/src/main/java/com/nonid/network/MessStateS2CPayload.java @@ -0,0 +1,26 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record MessStateS2CPayload(UUID playerUuid, int vMess, int aMess, int mMess, int tintRgb, int liquidStored, int liquidCapacity) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "mess_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.ofStatic((buf, payload) -> { + buf.writeUuid(payload.playerUuid); + buf.writeVarInt(payload.vMess); + buf.writeVarInt(payload.aMess); + buf.writeVarInt(payload.mMess); + buf.writeVarInt(payload.tintRgb); + buf.writeVarInt(payload.liquidStored); + buf.writeVarInt(payload.liquidCapacity); + }, buf -> new MessStateS2CPayload(buf.readUuid(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt())); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationCancelC2SPayload.java b/src/main/java/com/nonid/network/PlayerAnimationCancelC2SPayload.java new file mode 100644 index 0000000..85d9c40 --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationCancelC2SPayload.java @@ -0,0 +1,20 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationCancelC2SPayload(UUID requestId) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_cancel")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, PlayerAnimationCancelC2SPayload::requestId, PlayerAnimationCancelC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationChoiceC2SPayload.java b/src/main/java/com/nonid/network/PlayerAnimationChoiceC2SPayload.java new file mode 100644 index 0000000..be1693e --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationChoiceC2SPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationChoiceC2SPayload(UUID requestId, int choiceIndex) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_choice")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + PlayerAnimationChoiceC2SPayload::requestId, + PacketCodecs.INTEGER, + PlayerAnimationChoiceC2SPayload::choiceIndex, + PlayerAnimationChoiceC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationConsentC2SPayload.java b/src/main/java/com/nonid/network/PlayerAnimationConsentC2SPayload.java new file mode 100644 index 0000000..01e2714 --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationConsentC2SPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationConsentC2SPayload(UUID requestId, boolean accepted) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_consent")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + PlayerAnimationConsentC2SPayload::requestId, + PacketCodecs.BOOLEAN, + PlayerAnimationConsentC2SPayload::accepted, + PlayerAnimationConsentC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationConsentS2CPayload.java b/src/main/java/com/nonid/network/PlayerAnimationConsentS2CPayload.java new file mode 100644 index 0000000..523b18e --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationConsentS2CPayload.java @@ -0,0 +1,37 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationConsentS2CPayload( + UUID requestId, UUID requesterUuid, String requesterName, String animationId, String displayName, String contentTagsJson, boolean joinRequest +) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_consent_prompt")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + PlayerAnimationConsentS2CPayload::requestId, + Uuids.PACKET_CODEC, + PlayerAnimationConsentS2CPayload::requesterUuid, + PacketCodecs.STRING, + PlayerAnimationConsentS2CPayload::requesterName, + PacketCodecs.STRING, + PlayerAnimationConsentS2CPayload::animationId, + PacketCodecs.STRING, + PlayerAnimationConsentS2CPayload::displayName, + PacketCodecs.STRING, + PlayerAnimationConsentS2CPayload::contentTagsJson, + PacketCodecs.BOOLEAN, + PlayerAnimationConsentS2CPayload::joinRequest, + PlayerAnimationConsentS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationRequestStatusS2CPayload.java b/src/main/java/com/nonid/network/PlayerAnimationRequestStatusS2CPayload.java new file mode 100644 index 0000000..878efcd --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationRequestStatusS2CPayload.java @@ -0,0 +1,27 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationRequestStatusS2CPayload(UUID requestId, String state, String messageKey) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_request_status")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + PlayerAnimationRequestStatusS2CPayload::requestId, + PacketCodecs.STRING, + PlayerAnimationRequestStatusS2CPayload::state, + PacketCodecs.STRING, + PlayerAnimationRequestStatusS2CPayload::messageKey, + PlayerAnimationRequestStatusS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAnimationSelectionS2CPayload.java b/src/main/java/com/nonid/network/PlayerAnimationSelectionS2CPayload.java new file mode 100644 index 0000000..6899d1b --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAnimationSelectionS2CPayload.java @@ -0,0 +1,32 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAnimationSelectionS2CPayload(UUID requestId, UUID targetUuid, String targetName, String candidatesJson, boolean joinRequest) + implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_animation_selection")); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, + PlayerAnimationSelectionS2CPayload::requestId, + Uuids.PACKET_CODEC, + PlayerAnimationSelectionS2CPayload::targetUuid, + PacketCodecs.STRING, + PlayerAnimationSelectionS2CPayload::targetName, + PacketCodecs.STRING, + PlayerAnimationSelectionS2CPayload::candidatesJson, + PacketCodecs.BOOLEAN, + PlayerAnimationSelectionS2CPayload::joinRequest, + PlayerAnimationSelectionS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerAttackInputC2SPayload.java b/src/main/java/com/nonid/network/PlayerAttackInputC2SPayload.java new file mode 100644 index 0000000..f055f3d --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerAttackInputC2SPayload.java @@ -0,0 +1,22 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record PlayerAttackInputC2SPayload(UUID instanceId, int keyId) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "player_attack_input"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, PlayerAttackInputC2SPayload::instanceId, PacketCodecs.INTEGER, PlayerAttackInputC2SPayload::keyId, PlayerAttackInputC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerProfileActionC2SPayload.java b/src/main/java/com/nonid/network/PlayerProfileActionC2SPayload.java new file mode 100644 index 0000000..d3c8c5d --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerProfileActionC2SPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record PlayerProfileActionC2SPayload(String action, String target, String mode) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_profile_action")); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.STRING, + PlayerProfileActionC2SPayload::action, + PacketCodecs.STRING, + PlayerProfileActionC2SPayload::target, + PacketCodecs.STRING, + PlayerProfileActionC2SPayload::mode, + PlayerProfileActionC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerProfileSnapshotS2CPayload.java b/src/main/java/com/nonid/network/PlayerProfileSnapshotS2CPayload.java new file mode 100644 index 0000000..06607a1 --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerProfileSnapshotS2CPayload.java @@ -0,0 +1,23 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record PlayerProfileSnapshotS2CPayload(String json, String errorKey) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "player_profile_snapshot")); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.STRING, + PlayerProfileSnapshotS2CPayload::json, + PacketCodecs.STRING, + PlayerProfileSnapshotS2CPayload::errorKey, + PlayerProfileSnapshotS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PlayerRuntimeSettingsS2CPayload.java b/src/main/java/com/nonid/network/PlayerRuntimeSettingsS2CPayload.java new file mode 100644 index 0000000..fea39d5 --- /dev/null +++ b/src/main/java/com/nonid/network/PlayerRuntimeSettingsS2CPayload.java @@ -0,0 +1,20 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record PlayerRuntimeSettingsS2CPayload(int attackEscapeHits) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "player_runtime_settings"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.INTEGER, PlayerRuntimeSettingsS2CPayload::attackEscapeHits, PlayerRuntimeSettingsS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PregnancyCycleStateS2CPayload.java b/src/main/java/com/nonid/network/PregnancyCycleStateS2CPayload.java new file mode 100644 index 0000000..b43b399 --- /dev/null +++ b/src/main/java/com/nonid/network/PregnancyCycleStateS2CPayload.java @@ -0,0 +1,35 @@ +package com.nonid.network; + +import com.nonid.PregnancyCyclePhase; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record PregnancyCycleStateS2CPayload(boolean enabled, int day, PregnancyCyclePhase phase) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "pregnancy_cycle_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.BOOLEAN, + PregnancyCycleStateS2CPayload::enabled, + PacketCodecs.INTEGER, + PregnancyCycleStateS2CPayload::day, + PacketCodecs.INTEGER, + payload -> payload.phase().ordinal(), + PregnancyCycleStateS2CPayload::fromPacket + ); + + public PregnancyCycleStateS2CPayload { + phase = phase == null ? PregnancyCyclePhase.NORMAL : phase; + } + + private static PregnancyCycleStateS2CPayload fromPacket(boolean enabled, int day, int phaseId) { + return new PregnancyCycleStateS2CPayload(enabled, day, PregnancyCyclePhase.fromNetworkId(phaseId)); + } + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/PregnancyStateS2CPayload.java b/src/main/java/com/nonid/network/PregnancyStateS2CPayload.java new file mode 100644 index 0000000..e4db307 --- /dev/null +++ b/src/main/java/com/nonid/network/PregnancyStateS2CPayload.java @@ -0,0 +1,20 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record PregnancyStateS2CPayload(String pregnantEntityTypeId) implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "pregnancy_state"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.STRING, PregnancyStateS2CPayload::pregnantEntityTypeId, PregnancyStateS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/RepairDestroyedSkinC2SPayload.java b/src/main/java/com/nonid/network/RepairDestroyedSkinC2SPayload.java new file mode 100644 index 0000000..bf7fc32 --- /dev/null +++ b/src/main/java/com/nonid/network/RepairDestroyedSkinC2SPayload.java @@ -0,0 +1,17 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record RepairDestroyedSkinC2SPayload() implements CustomPayload { + public static final Identifier REPAIR_DESTROYED_SKIN_ID = Identifier.of("needsofnature", "repair_destroyed_skin"); + public static final Id ID = new Id(REPAIR_DESTROYED_SKIN_ID); + public static final PacketCodec CODEC = PacketCodec.unit(new RepairDestroyedSkinC2SPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/ServerConfigRequestC2SPayload.java b/src/main/java/com/nonid/network/ServerConfigRequestC2SPayload.java new file mode 100644 index 0000000..2bb8225 --- /dev/null +++ b/src/main/java/com/nonid/network/ServerConfigRequestC2SPayload.java @@ -0,0 +1,16 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record ServerConfigRequestC2SPayload() implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "server_config_request")); + public static final PacketCodec CODEC = PacketCodec.unit(new ServerConfigRequestC2SPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/ServerConfigResultS2CPayload.java b/src/main/java/com/nonid/network/ServerConfigResultS2CPayload.java new file mode 100644 index 0000000..aa26a8a --- /dev/null +++ b/src/main/java/com/nonid/network/ServerConfigResultS2CPayload.java @@ -0,0 +1,25 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record ServerConfigResultS2CPayload(boolean success, long revision, String messageKey) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "server_config_result")); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.BOOLEAN, + ServerConfigResultS2CPayload::success, + PacketCodecs.LONG, + ServerConfigResultS2CPayload::revision, + PacketCodecs.STRING, + ServerConfigResultS2CPayload::messageKey, + ServerConfigResultS2CPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/ServerConfigUpdateC2SPayload.java b/src/main/java/com/nonid/network/ServerConfigUpdateC2SPayload.java new file mode 100644 index 0000000..0f3449d --- /dev/null +++ b/src/main/java/com/nonid/network/ServerConfigUpdateC2SPayload.java @@ -0,0 +1,23 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record ServerConfigUpdateC2SPayload(long revision, String json) implements CustomPayload { + public static final Id ID = new Id(Identifier.of("needsofnature", "server_config_update")); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.LONG, + ServerConfigUpdateC2SPayload::revision, + PacketCodecs.string(262144), + ServerConfigUpdateC2SPayload::json, + ServerConfigUpdateC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/SetPlayerGenderC2SPayload.java b/src/main/java/com/nonid/network/SetPlayerGenderC2SPayload.java new file mode 100644 index 0000000..01b7d40 --- /dev/null +++ b/src/main/java/com/nonid/network/SetPlayerGenderC2SPayload.java @@ -0,0 +1,20 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.codec.PacketCodecs; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record SetPlayerGenderC2SPayload(int mask, boolean initialSelection) implements CustomPayload { + public static final Identifier SET_PLAYER_GENDER_ID = Identifier.of("needsofnature", "set_player_gender"); + public static final Id ID = new Id(SET_PLAYER_GENDER_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.INTEGER, SetPlayerGenderC2SPayload::mask, PacketCodecs.BOOLEAN, SetPlayerGenderC2SPayload::initialSelection, SetPlayerGenderC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/SkinRippedInfoRequestS2CPayload.java b/src/main/java/com/nonid/network/SkinRippedInfoRequestS2CPayload.java new file mode 100644 index 0000000..8802591 --- /dev/null +++ b/src/main/java/com/nonid/network/SkinRippedInfoRequestS2CPayload.java @@ -0,0 +1,17 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record SkinRippedInfoRequestS2CPayload() implements CustomPayload { + public static final Identifier ID_RAW = Identifier.of("needsofnature", "skin_ripped_info_request"); + public static final Id ID = new Id(ID_RAW); + public static final PacketCodec CODEC = PacketCodec.unit(new SkinRippedInfoRequestS2CPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/StartManualPeakC2SPayload.java b/src/main/java/com/nonid/network/StartManualPeakC2SPayload.java new file mode 100644 index 0000000..adda962 --- /dev/null +++ b/src/main/java/com/nonid/network/StartManualPeakC2SPayload.java @@ -0,0 +1,17 @@ +package com.nonid.network; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; + +public record StartManualPeakC2SPayload() implements CustomPayload { + public static final Identifier START_MANUAL_PEAK_ID = Identifier.of("needsofnature", "start_manual_peak"); + public static final Id ID = new Id(START_MANUAL_PEAK_ID); + public static final PacketCodec CODEC = PacketCodec.unit(new StartManualPeakC2SPayload()); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/StopAttackAnimationC2SPayload.java b/src/main/java/com/nonid/network/StopAttackAnimationC2SPayload.java new file mode 100644 index 0000000..29d089b --- /dev/null +++ b/src/main/java/com/nonid/network/StopAttackAnimationC2SPayload.java @@ -0,0 +1,21 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record StopAttackAnimationC2SPayload(UUID instanceId) implements CustomPayload { + public static final Identifier STOP_ATTACK_ANIMATION_ID = Identifier.of("needsofnature", "stop_attack_animation"); + public static final Id ID = new Id(STOP_ATTACK_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, StopAttackAnimationC2SPayload::instanceId, StopAttackAnimationC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/network/StopVoluntaryAnimationC2SPayload.java b/src/main/java/com/nonid/network/StopVoluntaryAnimationC2SPayload.java new file mode 100644 index 0000000..4ed2d39 --- /dev/null +++ b/src/main/java/com/nonid/network/StopVoluntaryAnimationC2SPayload.java @@ -0,0 +1,21 @@ +package com.nonid.network; + +import java.util.UUID; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.CustomPayload.Id; +import net.minecraft.util.Identifier; +import net.minecraft.util.Uuids; + +public record StopVoluntaryAnimationC2SPayload(UUID instanceId) implements CustomPayload { + public static final Identifier STOP_VOLUNTARY_ANIMATION_ID = Identifier.of("needsofnature", "stop_voluntary_animation"); + public static final Id ID = new Id(STOP_VOLUNTARY_ANIMATION_ID); + public static final PacketCodec CODEC = PacketCodec.tuple( + Uuids.PACKET_CODEC, StopVoluntaryAnimationC2SPayload::instanceId, StopVoluntaryAnimationC2SPayload::new + ); + + public Id getId() { + return ID; + } +} diff --git a/src/main/java/com/nonid/pack/NonExternalPackCatalog.java b/src/main/java/com/nonid/pack/NonExternalPackCatalog.java new file mode 100644 index 0000000..b21676a --- /dev/null +++ b/src/main/java/com/nonid/pack/NonExternalPackCatalog.java @@ -0,0 +1,99 @@ +package com.nonid.pack; + +import com.nonid.NeedsOfNature; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +public final class NonExternalPackCatalog { + private NonExternalPackCatalog() { + } + + public static List listFileNames(Path directory) { + return listPaths(directory).stream().map(NonExternalPackCatalog::fileName).toList(); + } + + public static List resolveHighToLow(Path directory, List configuredOrder) { + List paths = listPaths(directory); + if (paths.isEmpty()) { + return List.of(); + } + + Map pathByName = new LinkedHashMap<>(); + + for (Path path : paths) { + pathByName.put(fileName(path), path); + } + + List orderedNames = normalizeOrder(configuredOrder, List.copyOf(pathByName.keySet())); + List entries = new ArrayList<>(orderedNames.size()); + + for (String name : orderedNames) { + Path path = pathByName.get(name); + if (path != null) { + NonPackRootResolver.resolve(path).ifPresent(root -> entries.add(new NonExternalPackCatalog.Entry(name, path, root))); + } + } + + return List.copyOf(entries); + } + + public static List resolveLowToHigh(Path directory, List configuredOrder) { + List entries = new ArrayList<>(resolveHighToLow(directory, configuredOrder)); + Collections.reverse(entries); + return List.copyOf(entries); + } + + public static List normalizeOrder(List configuredOrder, List discoveredNames) { + List discovered = discoveredNames == null ? List.of() : discoveredNames; + if (discovered.isEmpty()) { + return List.of(); + } + + LinkedHashSet discoveredSet = new LinkedHashSet<>(discovered); + LinkedHashSet ordered = new LinkedHashSet<>(); + if (configuredOrder != null) { + for (String raw : configuredOrder) { + if (raw != null) { + String name = raw.trim(); + if (!name.isEmpty() && discoveredSet.contains(name)) { + ordered.add(name); + } + } + } + } + + ordered.addAll(discovered); + return List.copyOf(ordered); + } + + private static List listPaths(Path directory) { + if (directory != null && Files.isDirectory(directory)) { + try (Stream paths = Files.list(directory)) { + return paths.filter(NonPackRootResolver::isSupportedExternalPackPath) + .sorted((left, right) -> String.CASE_INSENSITIVE_ORDER.compare(fileName(left), fileName(right))) + .toList(); + } catch (IOException e) { + NeedsOfNature.logSetupWarning("[NoN] Failed to list external NoN packs in {}.", directory, e); + return List.of(); + } + } else { + return List.of(); + } + } + + private static String fileName(Path path) { + Path fileName = path == null ? null : path.getFileName(); + return fileName == null ? String.valueOf(path) : fileName.toString(); + } + + public record Entry(String fileName, Path path, NonPackRootResolver.ResolvedRoot root) { + } +} diff --git a/src/main/java/com/nonid/pack/NonExternalPackProvider.java b/src/main/java/com/nonid/pack/NonExternalPackProvider.java new file mode 100644 index 0000000..80d85f9 --- /dev/null +++ b/src/main/java/com/nonid/pack/NonExternalPackProvider.java @@ -0,0 +1,191 @@ +package com.nonid.pack; + +import com.mojang.logging.LogUtils; +import com.nonid.NeedsOfNature; +import com.nonid.NonConfig; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.resource.ResourcePackInfo; +import net.minecraft.resource.ResourcePackPosition; +import net.minecraft.resource.ResourcePackProfile; +import net.minecraft.resource.ResourcePackProvider; +import net.minecraft.resource.ResourcePackSource; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.DirectoryResourcePack.DirectoryBackedFactory; +import net.minecraft.resource.ResourcePackProfile.InsertionPosition; +import net.minecraft.resource.ResourcePackProfile.PackFactory; +import net.minecraft.resource.ZipResourcePack.ZipBackedFactory; +import net.minecraft.text.Text; +import net.minecraft.util.path.PathUtil; +import net.minecraft.util.path.SymlinkEntry; +import net.minecraft.util.path.SymlinkFinder; +import net.minecraft.util.path.SymlinkValidationException; +import org.slf4j.Logger; + +public final class NonExternalPackProvider implements ResourcePackProvider { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final String EXTERNAL_PACKS_DIR_NAME = "needsofnature"; + private static final ResourcePackSource NON_SOURCE = ResourcePackSource.create(name -> Text.literal("[NoN] ").append(name), true); + private static final ResourcePackPosition ALWAYS_ENABLED_TOP = new ResourcePackPosition(true, InsertionPosition.TOP, false); + private final Path packsDir; + private final ResourceType type; + private final SymlinkFinder symlinkFinder; + + public NonExternalPackProvider(Path packsDir, ResourceType type, SymlinkFinder symlinkFinder) { + this.packsDir = packsDir; + this.type = type; + this.symlinkFinder = symlinkFinder; + } + + public ResourceType getType() { + return this.type; + } + + public static Path resolveDefaultPacksDir() { + return FabricLoader.getInstance().getGameDir().resolve("needsofnature"); + } + + public static List listExternalPackFileNames(Path dir) { + return NonExternalPackCatalog.listFileNames(dir); + } + + public static List normalizeConfiguredOrder(List configuredOrder, List discoveredNames) { + return NonExternalPackCatalog.normalizeOrder(configuredOrder, discoveredNames); + } + + public void register(Consumer profileAdder) { + try { + PathUtil.createDirectories(this.packsDir); + List discovered = NonExternalPackCatalog.resolveHighToLow(this.packsDir, resolveConfiguredOrder()) + .stream() + .filter(entry -> this.isSymlinkSafe(entry.root())) + .map(entry -> new NonExternalPackProvider.PackCandidate(entry.path(), createPackFactory(entry.root()))) + .toList(); + List normalizedOrder = discovered.stream().map(NonExternalPackProvider.PackCandidate::fileName).toList(); + Map indexByName = indexByName(normalizedOrder); + + for (int i = discovered.size() - 1; i >= 0; i--) { + NonExternalPackProvider.PackCandidate candidate = discovered.get(i); + int rank = indexByName.getOrDefault(candidate.fileName(), Integer.MAX_VALUE); + ResourcePackInfo info = this.createPackInfo(candidate.path(), rank); + ResourcePackProfile profile = ResourcePackProfile.create(info, candidate.packFactory(), this.type, ALWAYS_ENABLED_TOP); + if (profile != null) { + profileAdder.accept(profile); + } + } + } catch (IOException e) { + LOGGER.warn("Failed to list NoN packs in {}", this.packsDir, e); + } + } + + private boolean isSymlinkSafe(NonPackRootResolver.ResolvedRoot root) { + if (root != null && this.symlinkFinder != null) { + List entries = new ArrayList<>(); + + try { + if (root.isZip()) { + if (Files.isSymbolicLink(root.sourcePath())) { + this.symlinkFinder.validate(root.sourcePath(), entries); + } + } else { + this.symlinkFinder.validateRecursively(root.rootPath(), entries); + } + } catch (IOException e) { + LOGGER.warn("Failed to validate NoN pack symlinks in {}, ignoring", root.sourcePath(), e); + return false; + } + + if (!entries.isEmpty()) { + LOGGER.warn("Ignoring potential NoN pack entry: {}", SymlinkValidationException.getMessage(root.sourcePath(), entries)); + return false; + } else { + return true; + } + } else { + return true; + } + } + + private static PackFactory createPackFactory(NonPackRootResolver.ResolvedRoot root) { + if (root.isZip()) { + return (PackFactory)(root.isNested() + ? new NonPrefixedZipResourcePack.Factory(root.sourcePath(), root.zipPrefix()) + : new ZipBackedFactory(root.sourcePath())); + } else { + return new DirectoryBackedFactory(root.rootPath()); + } + } + + private ResourcePackInfo createPackInfo(Path path, int rank) { + String fileName = path.getFileName().toString(); + String channel = this.type == ResourceType.CLIENT_RESOURCES ? "client" : "server"; + String safeFileName = sanitizeForPackId(fileName); + String orderPrefix = formatOrderPrefix(rank); + String id = "needsofnature/" + channel + "/" + orderPrefix + "_" + safeFileName; + return new ResourcePackInfo(id, Text.literal(fileName), NON_SOURCE, Optional.empty()); + } + + private static List resolveConfiguredOrder() { + NonConfig config = NeedsOfNature.getConfig(); + return config != null ? config.getExternalNoNPackLoadOrder() : List.of(); + } + + private static Map indexByName(List names) { + Map index = new LinkedHashMap<>(); + if (names == null) { + return index; + } + + for (int i = 0; i < names.size(); i++) { + index.putIfAbsent(names.get(i), i); + } + + return index; + } + + private static String formatOrderPrefix(int rank) { + int clamped = rank; + if (clamped < 0) { + clamped = 0; + } + + if (clamped > 9999) { + clamped = 9999; + } + + int encoded = 9999 - clamped; + return String.format(Locale.ROOT, "%04d", encoded); + } + + private static String sanitizeForPackId(String fileName) { + if (fileName != null && !fileName.isBlank()) { + StringBuilder out = new StringBuilder(fileName.length()); + + for (int i = 0; i < fileName.length(); i++) { + char c = fileName.charAt(i); + boolean safe = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'; + out.append(safe ? c : '_'); + } + + return out.toString(); + } else { + return "unknown"; + } + } + + private record PackCandidate(Path path, PackFactory packFactory) { + private String fileName() { + Path fileName = this.path.getFileName(); + return fileName == null ? this.path.toString() : fileName.toString(); + } + } +} diff --git a/src/main/java/com/nonid/pack/NonGeneratedAccessoryModelPackProvider.java b/src/main/java/com/nonid/pack/NonGeneratedAccessoryModelPackProvider.java new file mode 100644 index 0000000..be16416 --- /dev/null +++ b/src/main/java/com/nonid/pack/NonGeneratedAccessoryModelPackProvider.java @@ -0,0 +1,147 @@ +package com.nonid.pack; + +import com.nonid.data.NonAccessoryItemRegistry; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Consumer; +import net.minecraft.resource.ResourcePackProfile; +import net.minecraft.resource.ResourcePackProvider; +import net.minecraft.resource.ResourceType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public final class NonGeneratedAccessoryModelPackProvider implements ResourcePackProvider { + public void register(Consumer profileAdder) { + Map resources = buildGeneratedResources(); + if (!resources.isEmpty()) { + ResourcePackProfile profile = NonGeneratedInMemoryPack.createProfile( + "needsofnature/client/generated_accessory_models", + Text.literal("NoN 生成的配件模型"), + Text.literal("生成的 NoN 配件物品模型"), + ResourceType.CLIENT_RESOURCES, + resources + ); + if (profile != null) { + profileAdder.accept(profile); + } + } + } + + private static Map buildGeneratedResources() { + Map resources = new LinkedHashMap<>(); + + for (Entry entry : NonAccessoryItemRegistry.getClientModelsByItem().entrySet()) { + Identifier itemId = entry.getKey(); + NonAccessoryItemRegistry.ItemClientModel model = entry.getValue(); + if (model.itemModel() != null) { + addItemDefinition(resources, itemId, model); + } + + if (!model.layers().isEmpty()) { + addGeneratedItemModel(resources, itemId, model.layers()); + List emptyLayers = withoutStoredLiquidLayers(model.layers()); + if (emptyLayers.size() != model.layers().size()) { + addGeneratedItemModel(resources, Identifier.of(itemId.getNamespace(), itemId.getPath() + "_empty"), emptyLayers); + } + } + } + + return resources; + } + + private static void addItemDefinition(Map resources, Identifier itemId, NonAccessoryItemRegistry.ItemClientModel model) { + StringBuilder json = new StringBuilder("{\n \"model\": "); + List emptyLayers = withoutStoredLiquidLayers(model.layers()); + if (emptyLayers.size() != model.layers().size()) { + Identifier emptyModel = Identifier.of(itemId.getNamespace(), "item/" + itemId.getPath() + "_empty"); + json.append("{\n") + .append(" \"type\": \"minecraft:condition\",\n") + .append(" \"property\": \"minecraft:custom_model_data\",\n") + .append(" \"index\": 0,\n") + .append(" \"on_true\": ") + .append(basicModelJson(model.itemModel(), model.layers(), " ")) + .append(",\n \"on_false\": ") + .append(basicModelJson(emptyModel, emptyLayers, " ")) + .append("\n }"); + } else { + json.append(basicModelJson(model.itemModel(), model.layers(), " ")); + } + + json.append("\n}\n"); + resources.put(Identifier.of(itemId.getNamespace(), "items/" + itemId.getPath() + ".json"), json.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String basicModelJson(Identifier modelId, List layers, String indent) { + if (layers.isEmpty()) { + return "{\n" + indent + " \"type\": \"minecraft:empty\"\n" + indent + "}"; + } + + StringBuilder json = new StringBuilder("{\n") + .append(indent) + .append(" \"type\": \"minecraft:model\",\n") + .append(indent) + .append(" \"model\": \"") + .append(modelId) + .append('"'); + int lastTintIndex = lastTintIndex(layers); + if (lastTintIndex >= 0) { + json.append(",\n").append(indent).append(" \"tints\": [\n"); + + for (int i = 0; i <= lastTintIndex; i++) { + json.append(indent).append(" ").append(tintJson(layers.get(i).tintMode())); + if (i < lastTintIndex) { + json.append(','); + } + + json.append('\n'); + } + + json.append(indent).append(" ]\n").append(indent).append('}'); + } else { + json.append('\n').append(indent).append('}'); + } + + return json.toString(); + } + + private static void addGeneratedItemModel(Map resources, Identifier itemId, List layers) { + StringBuilder json = new StringBuilder("{\n \"parent\": \"minecraft:item/generated\",\n \"textures\": {\n"); + + for (int i = 0; i < layers.size(); i++) { + json.append(" \"layer").append(i).append("\": \"").append(layers.get(i).texture()).append('"'); + if (i + 1 < layers.size()) { + json.append(','); + } + + json.append('\n'); + } + + json.append(" }\n}\n"); + resources.put(Identifier.of(itemId.getNamespace(), "models/item/" + itemId.getPath() + ".json"), json.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static int lastTintIndex(List layers) { + for (int i = layers.size() - 1; i >= 0; i--) { + if (layers.get(i).tintMode() != NonAccessoryItemRegistry.TintMode.NONE) { + return i; + } + } + + return -1; + } + + private static List withoutStoredLiquidLayers(List layers) { + return layers.stream().filter(layer -> layer.tintMode() != NonAccessoryItemRegistry.TintMode.STORED_LIQUID).toList(); + } + + private static String tintJson(NonAccessoryItemRegistry.TintMode tintMode) { + return switch (tintMode) { + case DYE -> "{ \"type\": \"minecraft:dye\", \"default\": -1 }"; + case STORED_LIQUID -> "{ \"type\": \"minecraft:custom_model_data\", \"index\": 0, \"default\": -1 }"; + case NONE -> "{ \"type\": \"minecraft:constant\", \"value\": -1 }"; + }; + } +} diff --git a/src/main/java/com/nonid/pack/NonGeneratedAccessoryTagPackProvider.java b/src/main/java/com/nonid/pack/NonGeneratedAccessoryTagPackProvider.java new file mode 100644 index 0000000..0a1bdfc --- /dev/null +++ b/src/main/java/com/nonid/pack/NonGeneratedAccessoryTagPackProvider.java @@ -0,0 +1,72 @@ +package com.nonid.pack; + +import com.nonid.NonTrinketsIntegration; +import com.nonid.data.NonAccessoryItemRegistry; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.minecraft.resource.ResourcePackProfile; +import net.minecraft.resource.ResourcePackProvider; +import net.minecraft.resource.ResourceType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public final class NonGeneratedAccessoryTagPackProvider implements ResourcePackProvider { + public void register(Consumer profileAdder) { + if (NonTrinketsIntegration.isTrinketsLoaded()) { + Map resources = buildGeneratedResources(); + if (!resources.isEmpty()) { + ResourcePackProfile profile = NonGeneratedInMemoryPack.createProfile( + "needsofnature/server/generated_accessory_tags", + Text.literal("NoN 生成的配件标签"), + Text.literal("生成的 NoN 配件饰品标签"), + ResourceType.SERVER_DATA, + resources + ); + if (profile != null) { + profileAdder.accept(profile); + } + } + } + } + + private static Map buildGeneratedResources() { + Map resources = new LinkedHashMap<>(); + addSlotTag(resources, "legs/v"); + addSlotTag(resources, "legs/a"); + addSlotTag(resources, "legs/d"); + addItemTag(resources, "minecraft", "dyeable", NonAccessoryItemRegistry.getDyeableItemIds()); + List accessoryIds = NonAccessoryItemRegistry.getRegisteredItemIds(); + addItemTag(resources, "minecraft", "enchantable/equippable", accessoryIds); + addItemTag(resources, "minecraft", "enchantable/vanishing", accessoryIds); + addItemTag(resources, "minecraft", "enchantable/durability", NonAccessoryItemRegistry.getDurabilityEnchantableItemIds()); + return resources; + } + + private static void addSlotTag(Map resources, String slotId) { + List itemIds = NonAccessoryItemRegistry.getItemIdsForTrinketsSlot(slotId); + addItemTag(resources, "trinkets", slotId, itemIds); + } + + private static void addItemTag(Map resources, String namespace, String path, List itemIds) { + if (!itemIds.isEmpty()) { + String resourcePath = "tags/item/" + path + ".json"; + StringBuilder json = new StringBuilder(); + json.append("{\n \"replace\": false,\n \"values\": [\n"); + + for (int i = 0; i < itemIds.size(); i++) { + json.append(" \"").append(itemIds.get(i)).append("\""); + if (i + 1 < itemIds.size()) { + json.append(','); + } + + json.append('\n'); + } + + json.append(" ]\n}\n"); + resources.put(Identifier.of(namespace, resourcePath), json.toString().getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/src/main/java/com/nonid/pack/NonGeneratedCemPackProvider.java b/src/main/java/com/nonid/pack/NonGeneratedCemPackProvider.java new file mode 100644 index 0000000..907110f --- /dev/null +++ b/src/main/java/com/nonid/pack/NonGeneratedCemPackProvider.java @@ -0,0 +1,425 @@ +package com.nonid.pack; + +import com.nonid.NeedsOfNature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.Map.Entry; +import java.util.function.Consumer; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.resource.ResourcePackProfile; +import net.minecraft.resource.ResourcePackProvider; +import net.minecraft.resource.ResourceType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public final class NonGeneratedCemPackProvider implements ResourcePackProvider { + private static final String CEM_ROOT = "assets/minecraft/optifine/cem/"; + private static final byte[] VANILLA_FALLBACK_JEM_BYTES = "{\n \"textureSize\": [64, 64],\n \"models\": []\n}\n".getBytes(StandardCharsets.UTF_8); + private final Path packsDir; + + public NonGeneratedCemPackProvider(Path packsDir) { + this.packsDir = packsDir; + } + + public void register(Consumer profileAdder) { + Map generatedResources = this.buildGeneratedResources(); + if (!generatedResources.isEmpty()) { + ResourcePackProfile profile = NonGeneratedInMemoryPack.createProfile( + "needsofnature/client/generated_cem", + Text.literal("NoN 生成的 CEM 规则"), + Text.literal("生成的 NoN CEM 性别与年龄规则"), + ResourceType.CLIENT_RESOURCES, + generatedResources + ); + if (profile != null) { + profileAdder.accept(profile); + } + } + } + + private Map buildGeneratedResources() { + List orderedSources = listOrderedScanSources(this.packsDir); + if (orderedSources.isEmpty()) { + return Map.of(); + } + + Map resolved = new LinkedHashMap<>(); + + for (NonGeneratedCemPackProvider.ScanSource source : orderedSources) { + NonGeneratedCemPackProvider.PackScan scan = scanSource(source); + if (!scan.isEmpty()) { + LinkedHashSet bases = new LinkedHashSet<>(); + bases.addAll(scan.explicitProperties); + bases.addAll(scan.variants.keySet()); + + for (String base : bases) { + if (!resolved.containsKey(base)) { + if (scan.explicitProperties.contains(base)) { + resolved.put(base, NonGeneratedCemPackProvider.Resolution.explicit()); + } else { + NonGeneratedCemPackProvider.CemVariants variants = scan.variants.get(base); + if (variants != null && variants.needsGeneratedProperties()) { + resolved.put(base, NonGeneratedCemPackProvider.Resolution.generated(variants)); + } + } + } + } + } + } + + if (resolved.isEmpty()) { + return Map.of(); + } + + Map generated = new LinkedHashMap<>(); + + for (Entry entry : resolved.entrySet()) { + String base = entry.getKey(); + NonGeneratedCemPackProvider.Resolution resolution = entry.getValue(); + if (resolution.kind != NonGeneratedCemPackProvider.ResolutionKind.EXPLICIT) { + generated.put(cemIdentifier(base + ".properties"), generatedPropertiesBytes(resolution.variants)); + generated.put(cemIdentifier(base + ".jem"), VANILLA_FALLBACK_JEM_BYTES); + putGeneratedVariant(generated, base, 3, resolution.variants.adultPrimaryBytes()); + putGeneratedVariant(generated, base, 2, resolution.variants.adultTwo); + putGeneratedVariant(generated, base, 5, resolution.variants.babyPrimaryBytes()); + putGeneratedVariant(generated, base, 4, resolution.variants.babyTwo); + } + } + + return generated; + } + + private static void putGeneratedVariant(Map generated, String base, int suffix, byte[] bytes) { + if (bytes != null && bytes.length != 0) { + generated.put(cemIdentifier(base + suffix + ".jem"), bytes); + } + } + + private static List listOrderedScanSources(Path externalDir) { + List ordered = new ArrayList<>(); + ordered.addAll(listOrderedExternalPackSources(externalDir)); + resolveBuiltinCemRoot().ifPresent(cemRoot -> ordered.add(NonGeneratedCemPackProvider.ScanSource.cemRoot("builtin", cemRoot))); + return List.copyOf(ordered); + } + + private static List listOrderedExternalPackSources(Path dir) { + List ordered = new ArrayList<>(); + + for (NonExternalPackCatalog.Entry entry : NonExternalPackCatalog.resolveHighToLow(dir, NeedsOfNature.getConfig().getExternalNoNPackLoadOrder())) { + ordered.add(NonGeneratedCemPackProvider.ScanSource.packRoot(entry.fileName(), entry.root())); + } + + return List.copyOf(ordered); + } + + private static Optional resolveBuiltinCemRoot() { + Optional container = FabricLoader.getInstance().getModContainer("needsofnature"); + return container.isEmpty() + ? Optional.empty() + : container.get().findPath("assets/minecraft/optifine/cem/".substring(0, "assets/minecraft/optifine/cem/".length() - 1)); + } + + private static NonGeneratedCemPackProvider.PackScan scanSource(NonGeneratedCemPackProvider.ScanSource source) { + if (source == null) { + return NonGeneratedCemPackProvider.PackScan.EMPTY; + } + + try { + if (source.kind == NonGeneratedCemPackProvider.ScanSourceKind.CEM_ROOT) { + return scanCemRoot(source.path); + } else if (source.packRoot == null) { + return NonGeneratedCemPackProvider.PackScan.EMPTY; + } else { + return source.packRoot.isZip() ? scanZipPack(source.packRoot) : scanDirectoryPack(source.packRoot.rootPath()); + } + } catch (IOException e) { + NeedsOfNature.LOGGER.debug("[NoN] Failed to scan {} for generated CEM rules.", source.debugName, e); + return NonGeneratedCemPackProvider.PackScan.EMPTY; + } + } + + private static NonGeneratedCemPackProvider.PackScan scanDirectoryPack(Path packPath) throws IOException { + Path cemRoot = packPath.resolve("assets/minecraft/optifine/cem/"); + return !Files.isDirectory(cemRoot) ? NonGeneratedCemPackProvider.PackScan.EMPTY : scanCemRoot(cemRoot); + } + + private static NonGeneratedCemPackProvider.PackScan scanCemRoot(Path cemRoot) throws IOException { + Map variants = new LinkedHashMap<>(); + Set explicitProperties = new LinkedHashSet<>(); + + try (Stream paths = Files.walk(cemRoot)) { + for (Path path : paths.filter(x$0 -> Files.isRegularFile(x$0)).toList()) { + String relative = cemRoot.relativize(path).toString().replace('\\', '/'); + byte[] bytes = relative.endsWith(".jem") ? Files.readAllBytes(path) : null; + processCemPath(relative, bytes, explicitProperties, variants); + } + } + + return new NonGeneratedCemPackProvider.PackScan(explicitProperties, variants); + } + + private static NonGeneratedCemPackProvider.PackScan scanZipPack(NonPackRootResolver.ResolvedRoot packRoot) throws IOException { + Set explicitProperties = new LinkedHashSet<>(); + Map variants = new LinkedHashMap<>(); + + try (ZipFile zipFile = new ZipFile(packRoot.sourcePath().toFile())) { + Enumeration entries = zipFile.entries(); + + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + String name = packRoot.stripZipPrefix(entry.getName()); + if (name != null && name.startsWith("assets/minecraft/optifine/cem/")) { + String relative = name.substring("assets/minecraft/optifine/cem/".length()); + byte[] bytes = relative.endsWith(".jem") ? zipFile.getInputStream(entry).readAllBytes() : null; + processCemPath(relative, bytes, explicitProperties, variants); + } + } + } + } + + return new NonGeneratedCemPackProvider.PackScan(explicitProperties, variants); + } + + private static void processCemPath( + String relativePath, byte[] bytes, Set explicitProperties, Map variants + ) { + if (relativePath != null && !relativePath.isBlank()) { + String normalized = relativePath.replace('\\', '/'); + if (normalized.endsWith(".properties")) { + String base = normalized.substring(0, normalized.length() - ".properties".length()); + if (!base.isBlank()) { + explicitProperties.add(base); + } + } else if (normalized.endsWith(".jem")) { + String stem = normalized.substring(0, normalized.length() - ".jem".length()); + boolean baby = false; + if (stem.endsWith("_baby") && stem.length() > "_baby".length()) { + stem = stem.substring(0, stem.length() - "_baby".length()); + baby = true; + } + + String base; + NonGeneratedCemPackProvider.CemVariantKind kind; + if (stem.endsWith("1") && stem.length() > 1) { + base = stem.substring(0, stem.length() - 1); + kind = NonGeneratedCemPackProvider.CemVariantKind.ONE; + } else if (stem.endsWith("2") && stem.length() > 1) { + base = stem.substring(0, stem.length() - 1); + kind = NonGeneratedCemPackProvider.CemVariantKind.TWO; + } else { + base = stem; + kind = NonGeneratedCemPackProvider.CemVariantKind.BASE; + } + + if (!base.isBlank()) { + NonGeneratedCemPackProvider.CemVariants entry = variants.computeIfAbsent(base, key -> new NonGeneratedCemPackProvider.CemVariants()); + entry.mark(kind, baby, bytes); + } + } + } + } + + private static Identifier cemIdentifier(String relativePath) { + return Identifier.of("minecraft", "optifine/cem/" + relativePath); + } + + private static byte[] generatedPropertiesBytes(NonGeneratedCemPackProvider.CemVariants variants) { + StringBuilder content = new StringBuilder(); + int rule = 1; + if (variants.hasAdultPrimary()) { + rule = appendPrimaryGenderRules(content, rule, 3, false); + } + + if (variants.hasAdultTwo) { + rule = appendAgeGenderRule(content, rule, 2, false, 2, true); + } + + if (variants.hasBabyPrimary()) { + rule = appendPrimaryGenderRules(content, rule, 5, true); + } + + if (variants.hasBabyTwo) { + appendAgeGenderRule(content, rule, 4, true, 2, true); + } + + return content.toString().getBytes(StandardCharsets.UTF_8); + } + + private static int appendPrimaryGenderRules(StringBuilder content, int rule, int modelSuffix, boolean baby) { + rule = appendAgeGenderRule(content, rule, modelSuffix, baby, 1, true); + return appendAgeGenderRule(content, rule, modelSuffix, baby, 3, true); + } + + private static int appendAgeGenderRule(StringBuilder content, int rule, int modelSuffix, boolean baby, int gender, boolean enabled) { + if (!enabled) { + return rule; + } + + appendRuleHeader(content, rule, modelSuffix); + content.append("nbt.").append(rule).append('.').append("NeedsOfNatureGender").append('=').append(gender).append('\n'); + content.append("baby.").append(rule).append('=').append(baby).append('\n'); + return rule + 1; + } + + private static void appendRuleHeader(StringBuilder content, int rule, int modelSuffix) { + if (!content.isEmpty()) { + content.append('\n'); + } + + content.append("models.").append(rule).append('=').append(modelSuffix).append('\n'); + } + + private enum CemVariantKind { + BASE, + ONE, + TWO; + } + + private static final class CemVariants { + private boolean hasAdultBase; + private boolean hasAdultOne; + private boolean hasAdultTwo; + private boolean hasBabyBase; + private boolean hasBabyOne; + private boolean hasBabyTwo; + private byte[] adultBase; + private byte[] adultOne; + private byte[] adultTwo; + private byte[] babyBase; + private byte[] babyOne; + private byte[] babyTwo; + + private void mark(NonGeneratedCemPackProvider.CemVariantKind kind, boolean baby, byte[] bytes) { + if (baby) { + switch (kind) { + case BASE: + this.hasBabyBase = true; + this.babyBase = bytes; + break; + case ONE: + this.hasBabyOne = true; + this.babyOne = bytes; + break; + case TWO: + this.hasBabyTwo = true; + this.babyTwo = bytes; + } + } else { + switch (kind) { + case BASE: + this.hasAdultBase = true; + this.adultBase = bytes; + break; + case ONE: + this.hasAdultOne = true; + this.adultOne = bytes; + break; + case TWO: + this.hasAdultTwo = true; + this.adultTwo = bytes; + } + } + } + + private boolean hasAdultPrimary() { + return this.hasAdultOne || this.hasAdultBase; + } + + private boolean hasBabyPrimary() { + return this.hasBabyOne || this.hasBabyBase; + } + + private byte[] adultPrimaryBytes() { + return this.adultOne != null ? this.adultOne : this.adultBase; + } + + private byte[] babyPrimaryBytes() { + return this.babyOne != null ? this.babyOne : this.babyBase; + } + + private boolean needsGeneratedProperties() { + return this.hasAdultModelRules() || this.hasBabyModelRules(); + } + + private boolean hasAdultModelRules() { + return this.hasAdultBase || this.hasAdultOne || this.hasAdultTwo; + } + + private boolean hasBabyModelRules() { + return this.hasBabyBase || this.hasBabyOne || this.hasBabyTwo; + } + } + + private record PackScan(Set explicitProperties, Map variants) { + private static final NonGeneratedCemPackProvider.PackScan EMPTY = new NonGeneratedCemPackProvider.PackScan(Set.of(), Map.of()); + + private boolean isEmpty() { + return this.explicitProperties.isEmpty() && this.variants.isEmpty(); + } + } + + private static final class Resolution { + private final NonGeneratedCemPackProvider.ResolutionKind kind; + private final NonGeneratedCemPackProvider.CemVariants variants; + + private Resolution(NonGeneratedCemPackProvider.ResolutionKind kind, NonGeneratedCemPackProvider.CemVariants variants) { + this.kind = kind; + this.variants = variants; + } + + private static NonGeneratedCemPackProvider.Resolution explicit() { + return new NonGeneratedCemPackProvider.Resolution(NonGeneratedCemPackProvider.ResolutionKind.EXPLICIT, null); + } + + private static NonGeneratedCemPackProvider.Resolution generated(NonGeneratedCemPackProvider.CemVariants variants) { + return new NonGeneratedCemPackProvider.Resolution(NonGeneratedCemPackProvider.ResolutionKind.GENERATED, variants); + } + } + + private enum ResolutionKind { + EXPLICIT, + GENERATED; + } + + private static final class ScanSource { + private final NonGeneratedCemPackProvider.ScanSourceKind kind; + private final String debugName; + private final Path path; + private final NonPackRootResolver.ResolvedRoot packRoot; + + private ScanSource(NonGeneratedCemPackProvider.ScanSourceKind kind, String debugName, Path path, NonPackRootResolver.ResolvedRoot packRoot) { + this.kind = kind; + this.debugName = debugName; + this.path = path; + this.packRoot = packRoot; + } + + private static NonGeneratedCemPackProvider.ScanSource packRoot(String debugName, NonPackRootResolver.ResolvedRoot packRoot) { + return new NonGeneratedCemPackProvider.ScanSource(NonGeneratedCemPackProvider.ScanSourceKind.PACK_ROOT, debugName, null, packRoot); + } + + private static NonGeneratedCemPackProvider.ScanSource cemRoot(String debugName, Path path) { + return new NonGeneratedCemPackProvider.ScanSource(NonGeneratedCemPackProvider.ScanSourceKind.CEM_ROOT, debugName, path, null); + } + } + + private enum ScanSourceKind { + PACK_ROOT, + CEM_ROOT; + } +} diff --git a/src/main/java/com/nonid/pack/NonGeneratedInMemoryPack.java b/src/main/java/com/nonid/pack/NonGeneratedInMemoryPack.java new file mode 100644 index 0000000..77cdda0 --- /dev/null +++ b/src/main/java/com/nonid/pack/NonGeneratedInMemoryPack.java @@ -0,0 +1,138 @@ +package com.nonid.pack; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.Map.Entry; +import net.minecraft.resource.AbstractFileResourcePack; +import net.minecraft.resource.InputSupplier; +import net.minecraft.resource.PackVersion; +import net.minecraft.resource.ResourcePack; +import net.minecraft.resource.ResourcePackInfo; +import net.minecraft.resource.ResourcePackPosition; +import net.minecraft.resource.ResourcePackProfile; +import net.minecraft.resource.ResourcePackSource; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.ResourcePack.ResultConsumer; +import net.minecraft.resource.ResourcePackProfile.InsertionPosition; +import net.minecraft.resource.ResourcePackProfile.Metadata; +import net.minecraft.resource.ResourcePackProfile.PackFactory; +import net.minecraft.resource.metadata.PackResourceMetadata; +import net.minecraft.resource.metadata.ResourceMetadataSerializer; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.dynamic.Range; + +final class NonGeneratedInMemoryPack { + private static final ResourcePackSource NON_SOURCE = ResourcePackSource.create(name -> Text.literal("[NoN] ").append(name), true); + private static final ResourcePackPosition ALWAYS_ENABLED_PINNED_TOP = new ResourcePackPosition(true, InsertionPosition.TOP, true); + + private NonGeneratedInMemoryPack() { + } + + static ResourcePackProfile createProfile(String id, Text displayName, Text description, ResourceType type, Map resources) { + if (resources != null && !resources.isEmpty()) { + ResourcePackInfo info = new ResourcePackInfo(id, displayName, NON_SOURCE, Optional.empty()); + return ResourcePackProfile.create( + info, new NonGeneratedInMemoryPack.GeneratedPackFactory(type, description, resources), type, ALWAYS_ENABLED_PINNED_TOP + ); + } else { + return null; + } + } + + private static final class GeneratedPackFactory implements PackFactory { + private final ResourceType type; + private final Text description; + private final Map resources; + + private GeneratedPackFactory(ResourceType type, Text description, Map resources) { + this.type = type; + this.description = description; + this.resources = Map.copyOf(resources); + } + + public ResourcePack open(ResourcePackInfo info) { + return new NonGeneratedInMemoryPack.GeneratedResourcePack(info, this.type, this.description, this.resources); + } + + public ResourcePack openWithOverlays(ResourcePackInfo info, Metadata metadata) { + return this.open(info); + } + } + + private static final class GeneratedResourcePack extends AbstractFileResourcePack { + private final ResourceType type; + private final Map resources; + private final Set namespaces; + private final PackResourceMetadata metadata; + private final byte[] packMcmeta; + + private GeneratedResourcePack(ResourcePackInfo info, ResourceType type, Text description, Map resources) { + super(info); + this.type = type; + this.resources = resources; + this.namespaces = namespaces(resources); + this.metadata = new PackResourceMetadata(description, new Range(PackVersion.of(75))); + this.packMcmeta = ("{\"pack\":{\"description\":\"" + escapeJson(description.getString()) + "\",\"pack_format\":75}}").getBytes(StandardCharsets.UTF_8); + } + + public InputSupplier openRoot(String... segments) { + return segments.length == 1 && "pack.mcmeta".equals(segments[0]) ? () -> new ByteArrayInputStream(this.packMcmeta) : null; + } + + public InputSupplier open(ResourceType type, Identifier id) { + if (type != this.type) { + return null; + } + + byte[] bytes = this.resources.get(id); + return bytes == null ? null : () -> new ByteArrayInputStream(bytes); + } + + public void findResources(ResourceType type, String namespace, String prefix, ResultConsumer consumer) { + if (type == this.type && consumer != null) { + for (Entry entry : this.resources.entrySet()) { + Identifier id = entry.getKey(); + if (id.getNamespace().equals(namespace) && id.getPath().startsWith(prefix)) { + byte[] bytes = entry.getValue(); + consumer.accept(id, (InputSupplier)() -> new ByteArrayInputStream(bytes)); + } + } + } + } + + public Set getNamespaces(ResourceType type) { + return type == this.type ? this.namespaces : Set.of(); + } + + public T parseMetadata(ResourceMetadataSerializer metadataSerializer) { + if (metadataSerializer == PackResourceMetadata.getSerializerFor(this.type)) { + return (T)this.metadata; + } else { + return (T)(metadataSerializer == PackResourceMetadata.DESCRIPTION_SERIALIZER ? this.metadata.description() : null); + } + } + + public void close() { + } + + private static Set namespaces(Map resources) { + LinkedHashSet out = new LinkedHashSet<>(); + + for (Identifier id : resources.keySet()) { + out.add(id.getNamespace()); + } + + return out.isEmpty() ? Set.of() : Set.copyOf(out); + } + + private static String escapeJson(String value) { + return value != null && !value.isEmpty() ? value.replace("\\", "\\\\").replace("\"", "\\\"") : ""; + } + } +} diff --git a/src/main/java/com/nonid/pack/NonPackRootResolver.java b/src/main/java/com/nonid/pack/NonPackRootResolver.java new file mode 100644 index 0000000..36bc9e2 --- /dev/null +++ b/src/main/java/com/nonid/pack/NonPackRootResolver.java @@ -0,0 +1,237 @@ +package com.nonid.pack; + +import com.nonid.NeedsOfNature; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.jetbrains.annotations.Nullable; + +public final class NonPackRootResolver { + private static final Set WARNED_NESTED_PACKS = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_INVALID_PACKS = ConcurrentHashMap.newKeySet(); + + private NonPackRootResolver() { + } + + public static boolean isSupportedExternalPackPath(Path path) { + if (path == null) { + return false; + } + + if (Files.isDirectory(path)) { + return true; + } + + Path fileName = path.getFileName(); + return fileName == null ? false : fileName.toString().toLowerCase(Locale.ROOT).endsWith(".zip"); + } + + public static Optional resolve(Path path) { + return resolve(path, true); + } + + public static Optional resolve(Path path, boolean warn) { + if (path == null || !Files.exists(path)) { + return Optional.empty(); + } + + if (Files.isDirectory(path)) { + return resolveDirectory(path, warn); + } + + Path fileName = path.getFileName(); + String lowerName = fileName == null ? "" : fileName.toString().toLowerCase(Locale.ROOT); + return !lowerName.endsWith(".zip") ? Optional.empty() : resolveZip(path, warn); + } + + private static Optional resolveDirectory(Path path, boolean warn) { + if (isPackRootDirectory(path)) { + return Optional.of(NonPackRootResolver.ResolvedRoot.directory(path, path, "", null)); + } + + LinkedHashSet candidates = new LinkedHashSet<>(); + + try (Stream entries = Files.list(path)) { + entries.filter(x$0 -> Files.isDirectory(x$0)).filter(NonPackRootResolver::isPackRootDirectory).forEach(candidates::add); + } catch (IOException e) { + if (warn) { + warnInvalid(path, "failed to inspect directory pack root"); + } + + return Optional.empty(); + } + + if (candidates.size() == 1) { + Path nestedRoot = candidates.iterator().next(); + String folder = fileName(nestedRoot); + if (warn) { + warnNested(path, folder); + } + + return Optional.of(NonPackRootResolver.ResolvedRoot.directory(path, nestedRoot, "", folder)); + } else { + if (warn) { + if (candidates.size() > 1) { + warnInvalid(path, "contains multiple one-level nested pack roots"); + } else { + warnInvalid(path, "does not contain assets/, data/, or pack.mcmeta at the root or inside one wrapper folder"); + } + } + + return Optional.empty(); + } + } + + private static Optional resolveZip(Path path, boolean warn) { + try (ZipFile zip = new ZipFile(path.toFile())) { + boolean rootMatches = false; + LinkedHashSet candidates = new LinkedHashSet<>(); + Enumeration entries = zip.entries(); + + while (entries.hasMoreElements()) { + String normalized = normalizeZipEntryName(entries.nextElement().getName()); + if (normalized != null && !normalized.isBlank()) { + if (isPackRootEntry(normalized)) { + rootMatches = true; + break; + } + + int slash = normalized.indexOf(47); + if (slash > 0 && slash < normalized.length() - 1) { + String top = normalized.substring(0, slash); + String nested = normalized.substring(slash + 1); + if (!isIgnoredTopLevelFolder(top) && isPackRootEntry(nested)) { + candidates.add(top); + } + } + } + } + + if (rootMatches) { + return Optional.of(NonPackRootResolver.ResolvedRoot.zip(path, "", null)); + } + + if (candidates.size() == 1) { + String folder = candidates.iterator().next(); + if (warn) { + warnNested(path, folder); + } + + return Optional.of(NonPackRootResolver.ResolvedRoot.zip(path, folder + "/", folder)); + } + + if (warn) { + if (candidates.size() > 1) { + warnInvalid(path, "contains multiple one-level nested pack roots"); + } else { + warnInvalid(path, "does not contain assets/, data/, or pack.mcmeta at the root or inside one wrapper folder"); + } + } + } catch (IOException e) { + if (warn) { + warnInvalid(path, "failed to inspect zip pack root"); + } + } + + return Optional.empty(); + } + + private static boolean isPackRootDirectory(Path path) { + return Files.isRegularFile(path.resolve("pack.mcmeta")) || Files.isDirectory(path.resolve("assets")) || Files.isDirectory(path.resolve("data")); + } + + private static boolean isPackRootEntry(String entryPath) { + return entryPath != null && !entryPath.isBlank() + ? "pack.mcmeta".equals(entryPath) || entryPath.startsWith("assets/") || entryPath.startsWith("data/") + : false; + } + + private static boolean isIgnoredTopLevelFolder(String folder) { + return "__MACOSX".equals(folder) || folder.startsWith("."); + } + + @Nullable + public static String normalizeZipEntryName(@Nullable String raw) { + if (raw == null) { + return null; + } + + String normalized = raw.replace('\\', '/'); + + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + + return !normalized.contains("../") && !normalized.equals("..") && !normalized.startsWith("../") ? normalized : null; + } + + private static void warnNested(Path path, String folder) { + String key = normalizedKey(path) + "|" + folder; + if (WARNED_NESTED_PACKS.add(key)) { + NeedsOfNature.logSetupWarning( + "[NoN] External pack '{}' has its pack contents inside the folder '{}'. NoN will load it, but released packs should place assets/, data/, and pack.mcmeta directly at the pack root.", + fileName(path), + folder + ); + } + } + + private static void warnInvalid(Path path, String reason) { + String key = normalizedKey(path) + "|" + reason; + if (WARNED_INVALID_PACKS.add(key)) { + NeedsOfNature.logSetupWarning("[NoN] Ignoring external NoN pack '{}': {}.", fileName(path), reason); + } + } + + private static String normalizedKey(Path path) { + return path == null ? "" : path.toAbsolutePath().normalize().toString(); + } + + private static String fileName(Path path) { + return path != null && path.getFileName() != null ? path.getFileName().toString() : String.valueOf(path); + } + + public record ResolvedRoot(Path sourcePath, Path rootPath, String zipPrefix, @Nullable String wrapperFolder) { + static NonPackRootResolver.ResolvedRoot directory(Path sourcePath, Path rootPath, String zipPrefix, @Nullable String wrapperFolder) { + return new NonPackRootResolver.ResolvedRoot(sourcePath, rootPath, zipPrefix, wrapperFolder); + } + + static NonPackRootResolver.ResolvedRoot zip(Path sourcePath, String zipPrefix, @Nullable String wrapperFolder) { + return new NonPackRootResolver.ResolvedRoot(sourcePath, sourcePath, zipPrefix == null ? "" : zipPrefix, wrapperFolder); + } + + public boolean isZip() { + return Files.isRegularFile(this.sourcePath); + } + + public boolean isNested() { + return this.wrapperFolder != null && !this.wrapperFolder.isBlank(); + } + + public String prefixPath(String relativePath) { + String relative = relativePath == null ? "" : relativePath.replace('\\', '/'); + return this.zipPrefix != null && !this.zipPrefix.isBlank() ? this.zipPrefix + relative : relative; + } + + @Nullable + public String stripZipPrefix(String entryPath) { + String normalized = NonPackRootResolver.normalizeZipEntryName(entryPath); + if (normalized == null) { + return null; + } else if (this.zipPrefix == null || this.zipPrefix.isBlank()) { + return normalized; + } else { + return !normalized.startsWith(this.zipPrefix) ? null : normalized.substring(this.zipPrefix.length()); + } + } + } +} diff --git a/src/main/java/com/nonid/pack/NonPrefixedZipResourcePack.java b/src/main/java/com/nonid/pack/NonPrefixedZipResourcePack.java new file mode 100644 index 0000000..a13d2b9 --- /dev/null +++ b/src/main/java/com/nonid/pack/NonPrefixedZipResourcePack.java @@ -0,0 +1,164 @@ +package com.nonid.pack; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import net.minecraft.resource.AbstractFileResourcePack; +import net.minecraft.resource.InputSupplier; +import net.minecraft.resource.ResourcePack; +import net.minecraft.resource.ResourcePackInfo; +import net.minecraft.resource.ResourceType; +import net.minecraft.resource.ResourcePack.ResultConsumer; +import net.minecraft.resource.ResourcePackProfile.Metadata; +import net.minecraft.resource.ResourcePackProfile.PackFactory; +import net.minecraft.util.Identifier; + +final class NonPrefixedZipResourcePack extends AbstractFileResourcePack { + private final ZipFile zipFile; + private final String rootPrefix; + + private NonPrefixedZipResourcePack(ResourcePackInfo info, Path zipPath, String rootPrefix) throws IOException { + super(info); + this.zipFile = new ZipFile(zipPath.toFile()); + this.rootPrefix = rootPrefix != null && !rootPrefix.isBlank() ? rootPrefix : ""; + } + + public InputSupplier openRoot(String... segments) { + String relative = joinRootSegments(segments); + if (relative == null) { + return null; + } + + ZipEntry entry = this.zipFile.getEntry(this.rootPrefix + relative); + return entry != null && !entry.isDirectory() ? InputSupplier.create(this.zipFile, entry) : null; + } + + public InputSupplier open(ResourceType type, Identifier id) { + if (type != null && id != null) { + String path = this.rootPrefix + type.getDirectory() + "/" + id.getNamespace() + "/" + id.getPath(); + ZipEntry entry = this.zipFile.getEntry(path); + return entry != null && !entry.isDirectory() ? InputSupplier.create(this.zipFile, entry) : null; + } else { + return null; + } + } + + public void findResources(ResourceType type, String namespace, String prefix, ResultConsumer consumer) { + if (type != null && namespace != null && prefix != null && consumer != null) { + String scanPrefix = this.rootPrefix + type.getDirectory() + "/" + namespace + "/" + prefix; + Enumeration entries = this.zipFile.entries(); + + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry != null && !entry.isDirectory()) { + String normalized = NonPackRootResolver.normalizeZipEntryName(entry.getName()); + if (normalized != null && normalized.startsWith(scanPrefix)) { + String idPath = normalized.substring((this.rootPrefix + type.getDirectory() + "/" + namespace + "/").length()); + if (!idPath.isBlank()) { + Identifier id = Identifier.tryParse(namespace, idPath); + if (id != null) { + consumer.accept(id, InputSupplier.create(this.zipFile, entry)); + } + } + } + } + } + } + } + + public Set getNamespaces(ResourceType type) { + if (type == null) { + return Set.of(); + } + + String typePrefix = this.rootPrefix + type.getDirectory() + "/"; + LinkedHashSet namespaces = new LinkedHashSet<>(); + Enumeration entries = this.zipFile.entries(); + + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry != null && !entry.isDirectory()) { + String normalized = NonPackRootResolver.normalizeZipEntryName(entry.getName()); + if (normalized != null && normalized.startsWith(typePrefix)) { + int namespaceStart = typePrefix.length(); + int namespaceEnd = normalized.indexOf(47, namespaceStart); + if (namespaceEnd > namespaceStart) { + String namespace = normalized.substring(namespaceStart, namespaceEnd); + if (Identifier.isNamespaceValid(namespace)) { + namespaces.add(namespace); + } + } + } + } + } + + return Set.copyOf(namespaces); + } + + public void close() { + try { + this.zipFile.close(); + } catch (IOException var2) { + } + } + + private static String joinRootSegments(String... segments) { + if (segments != null && segments.length != 0) { + StringBuilder out = new StringBuilder(); + String[] var2 = segments; + int var3 = var2.length; + int var4 = 0; + + while (var4 < var3) { + String segment = var2[var4]; + if (segment != null && !segment.isBlank()) { + String normalized = segment.replace('\\', '/'); + if (!normalized.contains("/") && !normalized.equals(".") && !normalized.equals("..")) { + if (!out.isEmpty()) { + out.append('/'); + } + + out.append(normalized); + var4++; + continue; + } + + return null; + } + + return null; + } + + return out.toString(); + } else { + return ""; + } + } + + static final class Factory implements PackFactory { + private final Path zipPath; + private final String rootPrefix; + + Factory(Path zipPath, String rootPrefix) { + this.zipPath = zipPath; + this.rootPrefix = rootPrefix; + } + + public ResourcePack open(ResourcePackInfo info) { + try { + return new NonPrefixedZipResourcePack(info, this.zipPath, this.rootPrefix); + } catch (IOException e) { + throw new RuntimeException("Failed to open prefixed NoN pack " + this.zipPath, e); + } + } + + public ResourcePack openWithOverlays(ResourcePackInfo info, Metadata metadata) { + return this.open(info); + } + } +} diff --git a/src/main/java/com/nonid/particle/NonParticles.java b/src/main/java/com/nonid/particle/NonParticles.java new file mode 100644 index 0000000..c022b53 --- /dev/null +++ b/src/main/java/com/nonid/particle/NonParticles.java @@ -0,0 +1,81 @@ +package com.nonid.particle; + +import com.nonid.NeedsOfNature; +import net.fabricmc.fabric.api.particle.v1.FabricParticleTypes; +import net.minecraft.particle.ParticleEffect; +import net.minecraft.particle.ParticleType; +import net.minecraft.particle.SimpleParticleType; +import net.minecraft.particle.TintedParticleEffect; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonParticles { + public static final Identifier LIQUID_PARTICLE_ID = NeedsOfNature.id("liquid_particle"); + public static final Identifier LIQUID_PARTICLE_FALLING_ID = NeedsOfNature.id("liquid_particle_falling"); + public static final Identifier LIQUID_PARTICLE_PUDDLE_ID = NeedsOfNature.id("liquid_particle_puddle"); + public static final Identifier LIQUID_PARTICLE_WATER_ID = NeedsOfNature.id("liquid_particle_water"); + public static final ParticleType LIQUID_PARTICLE = registerTinted(LIQUID_PARTICLE_ID); + public static final ParticleType LIQUID_PARTICLE_FALLING = registerTinted(LIQUID_PARTICLE_FALLING_ID); + public static final ParticleType LIQUID_PARTICLE_PUDDLE = registerTinted(LIQUID_PARTICLE_PUDDLE_ID); + public static final ParticleType LIQUID_PARTICLE_WATER = registerTinted(LIQUID_PARTICLE_WATER_ID); + public static final SimpleParticleType SMALLHEART = register(NeedsOfNature.id("smallheart")); + public static final SimpleParticleType RIPPED_FABRIC = register(NeedsOfNature.id("ripped_fabric")); + + private NonParticles() { + } + + public static void registerAll() { + } + + public static boolean isLiquidParticleId(@Nullable Identifier id) { + return LIQUID_PARTICLE_ID.equals(id) + || LIQUID_PARTICLE_FALLING_ID.equals(id) + || LIQUID_PARTICLE_PUDDLE_ID.equals(id) + || LIQUID_PARTICLE_WATER_ID.equals(id); + } + + @Nullable + public static ParticleEffect createLiquidEffect(@Nullable Identifier id, int rgb) { + if (LIQUID_PARTICLE_ID.equals(id)) { + return liquid(rgb); + } else if (LIQUID_PARTICLE_FALLING_ID.equals(id)) { + return liquidFalling(rgb); + } else if (LIQUID_PARTICLE_PUDDLE_ID.equals(id)) { + return liquidPuddle(rgb); + } else { + return LIQUID_PARTICLE_WATER_ID.equals(id) ? liquidWater(rgb) : null; + } + } + + public static TintedParticleEffect liquid(int rgb) { + return tinted(LIQUID_PARTICLE, rgb); + } + + public static TintedParticleEffect liquidFalling(int rgb) { + return tinted(LIQUID_PARTICLE_FALLING, rgb); + } + + public static TintedParticleEffect liquidPuddle(int rgb) { + return tinted(LIQUID_PARTICLE_PUDDLE, rgb); + } + + public static TintedParticleEffect liquidWater(int rgb) { + return tinted(LIQUID_PARTICLE_WATER, rgb); + } + + private static TintedParticleEffect tinted(ParticleType type, int rgb) { + return TintedParticleEffect.create(type, 0xFF000000 | rgb & 16777215); + } + + private static SimpleParticleType register(Identifier id) { + return (SimpleParticleType)Registry.register(Registries.PARTICLE_TYPE, id, FabricParticleTypes.simple()); + } + + private static ParticleType registerTinted(Identifier id) { + return (ParticleType)Registry.register( + Registries.PARTICLE_TYPE, id, FabricParticleTypes.complex(TintedParticleEffect::createCodec, TintedParticleEffect::createPacketCodec) + ); + } +} diff --git a/src/main/java/com/nonid/potion/NonBrewingRecipes.java b/src/main/java/com/nonid/potion/NonBrewingRecipes.java new file mode 100644 index 0000000..e5d9d0f --- /dev/null +++ b/src/main/java/com/nonid/potion/NonBrewingRecipes.java @@ -0,0 +1,55 @@ +package com.nonid.potion; + +import java.util.LinkedHashSet; +import java.util.Set; +import net.fabricmc.fabric.api.registry.FabricBrewingRecipeRegistryBuilder; +import net.fabricmc.fabric.api.registry.FabricBrewingRecipeRegistryBuilder.BuildCallback; +import net.minecraft.item.Item; +import net.minecraft.item.Items; +import net.minecraft.potion.Potion; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryKeys; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.registry.tag.TagKey; +import net.minecraft.util.Identifier; + +public final class NonBrewingRecipes { + private static final TagKey EGGS_TAG = TagKey.of(RegistryKeys.ITEM, Identifier.of("c", "eggs")); + + private NonBrewingRecipes() { + } + + public static void registerAll() { + FabricBrewingRecipeRegistryBuilder.BUILD.register((BuildCallback)builder -> { + RegistryEntry liquid = Registries.POTION.getEntry(NonPotions.LIQUID); + RegistryEntry fertileNectar = Registries.POTION.getEntry(NonPotions.FERTILE_NECTAR); + RegistryEntry energy = Registries.POTION.getEntry(NonPotions.ENERGY); + RegistryEntry energyStrong = Registries.POTION.getEntry(NonPotions.ENERGY_II); + RegistryEntry energyLong = Registries.POTION.getEntry(NonPotions.ENERGY_LONG); + RegistryEntry relief = Registries.POTION.getEntry(NonPotions.ENERGY_RELIEF); + RegistryEntry reliefStrong = Registries.POTION.getEntry(NonPotions.ENERGY_RELIEF_II); + RegistryEntry reliefLong = Registries.POTION.getEntry(NonPotions.ENERGY_RELIEF_LONG); + RegistryEntry fertility = Registries.POTION.getEntry(NonPotions.FERTILITY_INCREASER); + RegistryEntry fertilityStrong = Registries.POTION.getEntry(NonPotions.FERTILITY_INCREASER_II); + RegistryEntry fertilityLong = Registries.POTION.getEntry(NonPotions.FERTILITY_INCREASER_LONG); + builder.registerPotionRecipe(liquid, Items.CRIMSON_FUNGUS, energy); + builder.registerPotionRecipe(liquid, Items.WARPED_FUNGUS, relief); + builder.registerPotionRecipe(energy, Items.GLOWSTONE_DUST, energyStrong); + builder.registerPotionRecipe(energy, Items.REDSTONE, energyLong); + builder.registerPotionRecipe(relief, Items.GLOWSTONE_DUST, reliefStrong); + builder.registerPotionRecipe(relief, Items.REDSTONE, reliefLong); + Set eggItems = new LinkedHashSet<>(); + Registries.ITEM.iterateEntries(EGGS_TAG).forEach(entry -> eggItems.add((Item)entry.value())); + if (eggItems.isEmpty()) { + eggItems.add(Items.EGG); + } + + for (Item egg : eggItems) { + builder.registerPotionRecipe(fertileNectar, egg, fertility); + } + + builder.registerPotionRecipe(fertility, Items.GLOWSTONE_DUST, fertilityStrong); + builder.registerPotionRecipe(fertility, Items.REDSTONE, fertilityLong); + }); + } +} diff --git a/src/main/java/com/nonid/potion/NonPotions.java b/src/main/java/com/nonid/potion/NonPotions.java new file mode 100644 index 0000000..92d313b --- /dev/null +++ b/src/main/java/com/nonid/potion/NonPotions.java @@ -0,0 +1,150 @@ +package com.nonid.potion; + +import com.nonid.NeedsOfNature; +import com.nonid.effect.NonStatusEffects; +import java.util.Optional; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.ConsumableComponent; +import net.minecraft.component.type.ConsumableComponents; +import net.minecraft.component.type.NbtComponent; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.potion.Potion; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.sound.SoundEvents; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public final class NonPotions { + public static final String LIQUID_ENTITY_DATA_KEY = "liquid_entity"; + private static final ConsumableComponent HONEY_BOTTLE_DRINK_CONSUMABLE = ConsumableComponents.drink().sound(SoundEvents.ITEM_HONEY_BOTTLE_DRINK).build(); + private static final int ENERGY_BASE_DURATION_TICKS = 800; + private static final int ENERGY_STRONG_DURATION_TICKS = 400; + private static final int ENERGY_LONG_DURATION_TICKS = 1600; + private static final int FERTILITY_BASE_DURATION_TICKS = 9600; + private static final int FERTILITY_STRONG_DURATION_TICKS = 4800; + private static final int FERTILITY_LONG_DURATION_TICKS = 19200; + public static final Potion LIQUID = register("liquid", new Potion("needsofnature.liquid", new StatusEffectInstance[0])); + public static final Potion ENERGY = register( + "energy", + new Potion("needsofnature.energy", new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY), 800)}) + ); + public static final Potion ENERGY_II = register( + "energy_ii", + new Potion( + "needsofnature.energy_ii", new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY), 400, 1)} + ) + ); + public static final Potion ENERGY_LONG = register( + "energy_long", + new Potion( + "needsofnature.energy_long", new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY), 1600)} + ) + ); + public static final Potion ENERGY_RELIEF = register( + "energy_relief", + new Potion( + "needsofnature.energy_relief", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY_RELIEF), 800)} + ) + ); + public static final Potion ENERGY_RELIEF_II = register( + "energy_relief_ii", + new Potion( + "needsofnature.energy_relief_ii", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY_RELIEF), 400, 1)} + ) + ); + public static final Potion ENERGY_RELIEF_LONG = register( + "energy_relief_long", + new Potion( + "needsofnature.energy_relief_long", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.ENERGY_RELIEF), 1600)} + ) + ); + public static final Potion FERTILE_NECTAR = register("fertile_nectar", new Potion("needsofnature.fertile_nectar", new StatusEffectInstance[0])); + public static final Potion FERTILITY_INCREASER = register( + "fertility_increaser", + new Potion( + "needsofnature.fertility_increaser", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FERTILITY_INCREASER), 9600)} + ) + ); + public static final Potion FERTILITY_INCREASER_II = register( + "fertility_increaser_ii", + new Potion( + "needsofnature.fertility_increaser_ii", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FERTILITY_INCREASER), 4800, 1)} + ) + ); + public static final Potion FERTILITY_INCREASER_LONG = register( + "fertility_increaser_long", + new Potion( + "needsofnature.fertility_increaser_long", + new StatusEffectInstance[]{new StatusEffectInstance(Registries.STATUS_EFFECT.getEntry(NonStatusEffects.FERTILITY_INCREASER), 19200)} + ) + ); + + private NonPotions() { + } + + public static void registerAll() { + } + + public static boolean isLiquidPotion(Optional> potionEntry) { + return potionEntry != null && potionEntry.isPresent() && potionEntry.get().value() == LIQUID; + } + + public static boolean isLiquidBottle(ItemStack stack) { + if (stack != null && !stack.isEmpty() && stack.isOf(Items.POTION)) { + PotionContentsComponent contents = (PotionContentsComponent)stack.getOrDefault(DataComponentTypes.POTION_CONTENTS, PotionContentsComponent.DEFAULT); + return isLiquidPotion(contents.potion()); + } else { + return false; + } + } + + public static void applyHoneyBottleDrinkSound(ItemStack stack) { + if (stack != null && !stack.isEmpty()) { + stack.set(DataComponentTypes.CONSUMABLE, HONEY_BOTTLE_DRINK_CONSUMABLE); + } + } + + public static void setLiquidBottleEntityTypeId(ItemStack stack, @Nullable Identifier entityTypeId) { + if (stack != null && !stack.isEmpty()) { + NbtComponent.set(DataComponentTypes.CUSTOM_DATA, stack, nbt -> { + String value = entityTypeId == null ? "" : entityTypeId.toString(); + nbt.putString("liquid_entity", value); + }); + } + } + + @Nullable + public static Identifier getLiquidBottleEntityTypeId(ItemStack stack) { + if (!isLiquidBottle(stack)) { + return null; + } + + NbtComponent custom = (NbtComponent)stack.get(DataComponentTypes.CUSTOM_DATA); + if (custom != null && !custom.isEmpty()) { + NbtCompound nbt = custom.copyNbt(); + if (!nbt.contains("liquid_entity")) { + return null; + } + + String raw = nbt.getString("liquid_entity", ""); + return raw != null && !raw.isBlank() ? Identifier.tryParse(raw) : null; + } else { + return null; + } + } + + private static Potion register(String path, Potion potion) { + return (Potion)Registry.register(Registries.POTION, NeedsOfNature.id(path), potion); + } +} diff --git a/src/main/java/com/nonid/recipe/AbstractEnergyAdjustRecipe.java b/src/main/java/com/nonid/recipe/AbstractEnergyAdjustRecipe.java new file mode 100644 index 0000000..58ade02 --- /dev/null +++ b/src/main/java/com/nonid/recipe/AbstractEnergyAdjustRecipe.java @@ -0,0 +1,140 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import com.nonid.potion.NonPotions; +import java.util.List; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.potion.Potion; +import net.minecraft.recipe.CraftingRecipe; +import net.minecraft.recipe.Ingredient; +import net.minecraft.recipe.IngredientPlacement; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; +import net.minecraft.recipe.display.RecipeDisplay; +import net.minecraft.recipe.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.recipe.display.SlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.ItemSlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.StackSlotDisplay; +import net.minecraft.recipe.input.CraftingRecipeInput; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryWrapper.WrapperLookup; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.util.collection.DefaultedList; +import net.minecraft.world.World; + +abstract class AbstractEnergyAdjustRecipe implements CraftingRecipe { + private final CraftingRecipeCategory category; + private IngredientPlacement ingredientPlacement; + + protected AbstractEnergyAdjustRecipe(CraftingRecipeCategory category) { + this.category = category; + } + + public boolean matches(CraftingRecipeInput input, World world) { + int liquid = 0; + int honeycomb = 0; + int sugar = 0; + int flowerMix = 0; + int fungus = 0; + int nonEmpty = 0; + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty()) { + nonEmpty++; + if (NonPotions.isLiquidBottle(stack)) { + liquid++; + } else if (stack.isOf(Items.HONEYCOMB)) { + honeycomb++; + } else if (stack.isOf(Items.SUGAR)) { + sugar++; + } else if (stack.isOf(NeedsOfNature.FLOWER_MIX)) { + flowerMix++; + } else { + if (!this.isRequiredFungus(stack)) { + return false; + } + + fungus++; + } + } + } + + return nonEmpty == 5 && liquid == 1 && honeycomb == 1 && sugar == 1 && flowerMix == 1 && fungus == 1; + } + + public ItemStack craft(CraftingRecipeInput input, WrapperLookup registries) { + return this.getResultStack().copy(); + } + + public CraftingRecipeCategory getCategory() { + return this.category; + } + + public IngredientPlacement getIngredientPlacement() { + if (this.ingredientPlacement == null) { + this.ingredientPlacement = IngredientPlacement.forShapeless( + List.of( + NonRecipeIngredients.liquidBottle(), + Ingredient.ofItem(Items.HONEYCOMB), + Ingredient.ofItem(Items.SUGAR), + Ingredient.ofItem(NeedsOfNature.FLOWER_MIX), + Ingredient.ofItem(this.getRequiredFungusItem()) + ) + ); + } + + return this.ingredientPlacement; + } + + public List getDisplays() { + SlotDisplay liquidDisplay = new StackSlotDisplay(createLiquidDisplayStack()); + SlotDisplay honeycombDisplay = Ingredient.ofItem(Items.HONEYCOMB).toDisplay(); + SlotDisplay sugarDisplay = Ingredient.ofItem(Items.SUGAR).toDisplay(); + SlotDisplay flowerMixDisplay = Ingredient.ofItem(NeedsOfNature.FLOWER_MIX).toDisplay(); + SlotDisplay fungusDisplay = Ingredient.ofItem(this.getRequiredFungusItem()).toDisplay(); + return List.of( + new ShapelessCraftingRecipeDisplay( + List.of(liquidDisplay, honeycombDisplay, sugarDisplay, flowerMixDisplay, fungusDisplay), + new StackSlotDisplay(this.getResultStack()), + new ItemSlotDisplay(Items.CRAFTING_TABLE) + ) + ); + } + + public DefaultedList getRecipeRemainders(CraftingRecipeInput input) { + DefaultedList remainders = DefaultedList.ofSize(input.size(), ItemStack.EMPTY); + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty()) { + if (NonPotions.isLiquidBottle(stack)) { + remainders.set(i, new ItemStack(Items.GLASS_BOTTLE)); + } else { + remainders.set(i, stack.getItem().getRecipeRemainder()); + } + } + } + + return remainders; + } + + private static ItemStack createLiquidDisplayStack() { + RegistryEntry liquidPotion = Registries.POTION.getEntry(NonPotions.LIQUID); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, new PotionContentsComponent(liquidPotion)); + return stack; + } + + protected abstract Item getRequiredFungusItem(); + + protected abstract boolean isRequiredFungus(ItemStack var1); + + protected abstract ItemStack getResultStack(); + + public abstract RecipeSerializer getSerializer(); +} diff --git a/src/main/java/com/nonid/recipe/EnergyAugmenterRecipe.java b/src/main/java/com/nonid/recipe/EnergyAugmenterRecipe.java new file mode 100644 index 0000000..02738d3 --- /dev/null +++ b/src/main/java/com/nonid/recipe/EnergyAugmenterRecipe.java @@ -0,0 +1,34 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; + +public final class EnergyAugmenterRecipe extends AbstractEnergyAdjustRecipe { + public EnergyAugmenterRecipe(CraftingRecipeCategory category) { + super(category); + } + + @Override + protected boolean isRequiredFungus(ItemStack stack) { + return stack.isOf(Items.CRIMSON_FUNGUS); + } + + @Override + protected Item getRequiredFungusItem() { + return Items.CRIMSON_FUNGUS; + } + + @Override + protected ItemStack getResultStack() { + return new ItemStack(NeedsOfNature.ENERGY_AUGMENTER); + } + + @Override + public RecipeSerializer getSerializer() { + return NonRecipeSerializers.ENERGY_AUGMENTER_RECIPE; + } +} diff --git a/src/main/java/com/nonid/recipe/EnergyDiminisherRecipe.java b/src/main/java/com/nonid/recipe/EnergyDiminisherRecipe.java new file mode 100644 index 0000000..86505be --- /dev/null +++ b/src/main/java/com/nonid/recipe/EnergyDiminisherRecipe.java @@ -0,0 +1,34 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; + +public final class EnergyDiminisherRecipe extends AbstractEnergyAdjustRecipe { + public EnergyDiminisherRecipe(CraftingRecipeCategory category) { + super(category); + } + + @Override + protected boolean isRequiredFungus(ItemStack stack) { + return stack.isOf(Items.WARPED_FUNGUS); + } + + @Override + protected Item getRequiredFungusItem() { + return Items.WARPED_FUNGUS; + } + + @Override + protected ItemStack getResultStack() { + return new ItemStack(NeedsOfNature.ENERGY_DIMINISHER); + } + + @Override + public RecipeSerializer getSerializer() { + return NonRecipeSerializers.ENERGY_DIMINISHER_RECIPE; + } +} diff --git a/src/main/java/com/nonid/recipe/EnergyStabilizerRecipe.java b/src/main/java/com/nonid/recipe/EnergyStabilizerRecipe.java new file mode 100644 index 0000000..c578855 --- /dev/null +++ b/src/main/java/com/nonid/recipe/EnergyStabilizerRecipe.java @@ -0,0 +1,160 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import com.nonid.item.EnergyStabilizerItem; +import com.nonid.potion.NonPotions; +import java.util.List; +import java.util.Optional; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.potion.Potion; +import net.minecraft.recipe.CraftingRecipe; +import net.minecraft.recipe.Ingredient; +import net.minecraft.recipe.IngredientPlacement; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; +import net.minecraft.recipe.display.RecipeDisplay; +import net.minecraft.recipe.display.ShapedCraftingRecipeDisplay; +import net.minecraft.recipe.display.SlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.ItemSlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.StackSlotDisplay; +import net.minecraft.recipe.input.CraftingRecipeInput; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryWrapper.WrapperLookup; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.util.Identifier; +import net.minecraft.util.collection.DefaultedList; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +public final class EnergyStabilizerRecipe implements CraftingRecipe { + private final CraftingRecipeCategory category; + private IngredientPlacement ingredientPlacement; + + public EnergyStabilizerRecipe(CraftingRecipeCategory category) { + this.category = category; + } + + public boolean matches(CraftingRecipeInput input, World world) { + return findTargetEntity(input) != null; + } + + public ItemStack craft(CraftingRecipeInput input, WrapperLookup registries) { + Identifier target = findTargetEntity(input); + return target == null ? ItemStack.EMPTY : EnergyStabilizerItem.createStackForEntity(target); + } + + public CraftingRecipeCategory getCategory() { + return this.category; + } + + public RecipeSerializer getSerializer() { + return NonRecipeSerializers.ENERGY_STABILIZER_RECIPE; + } + + public IngredientPlacement getIngredientPlacement() { + if (this.ingredientPlacement == null) { + Ingredient liquidBottle = NonRecipeIngredients.liquidBottle(); + this.ingredientPlacement = IngredientPlacement.forMultipleSlots( + List.of( + Optional.of(liquidBottle), + Optional.of(Ingredient.ofItem(Items.WARPED_FUNGUS)), + Optional.of(liquidBottle), + Optional.of(Ingredient.ofItem(NeedsOfNature.ENERGY_DIMINISHER)), + Optional.of(Ingredient.ofItem(Items.GLOW_BERRIES)), + Optional.of(Ingredient.ofItem(NeedsOfNature.ENERGY_DIMINISHER)), + Optional.of(liquidBottle), + Optional.of(Ingredient.ofItem(Items.WARPED_FUNGUS)), + Optional.of(liquidBottle) + ) + ); + } + + return this.ingredientPlacement; + } + + public List getDisplays() { + SlotDisplay liquidDisplay = new StackSlotDisplay(createLiquidDisplayStack()); + SlotDisplay fungusDisplay = Ingredient.ofItem(Items.WARPED_FUNGUS).toDisplay(); + SlotDisplay diminisherDisplay = Ingredient.ofItem(NeedsOfNature.ENERGY_DIMINISHER).toDisplay(); + SlotDisplay glowBerryDisplay = Ingredient.ofItem(Items.GLOW_BERRIES).toDisplay(); + SlotDisplay resultDisplay = new StackSlotDisplay(EnergyStabilizerItem.createStackForEntity(null)); + return List.of( + new ShapedCraftingRecipeDisplay( + 3, + 3, + List.of( + liquidDisplay, fungusDisplay, liquidDisplay, diminisherDisplay, glowBerryDisplay, diminisherDisplay, liquidDisplay, fungusDisplay, liquidDisplay + ), + resultDisplay, + new ItemSlotDisplay(Items.CRAFTING_TABLE) + ) + ); + } + + public DefaultedList getRecipeRemainders(CraftingRecipeInput input) { + DefaultedList remainders = DefaultedList.ofSize(input.size(), ItemStack.EMPTY); + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty()) { + if (isCorner(i, input.getWidth()) && NonPotions.isLiquidBottle(stack)) { + remainders.set(i, new ItemStack(Items.GLASS_BOTTLE)); + } else { + remainders.set(i, stack.getItem().getRecipeRemainder()); + } + } + } + + return remainders; + } + + @Nullable + private static Identifier findTargetEntity(CraftingRecipeInput input) { + if (input.getWidth() == 3 && input.getHeight() == 3) { + int[] corners = new int[]{0, 2, 6, 8}; + Identifier entityTypeId = null; + + for (int corner : corners) { + ItemStack liquidStack = input.getStackInSlot(corner); + Identifier current = NonPotions.getLiquidBottleEntityTypeId(liquidStack); + if (current == null) { + return null; + } + + if (entityTypeId == null) { + entityTypeId = current; + } else if (!entityTypeId.equals(current)) { + return null; + } + } + + if (!input.getStackInSlot(1).isOf(Items.WARPED_FUNGUS)) { + return null; + } else if (!input.getStackInSlot(7).isOf(Items.WARPED_FUNGUS)) { + return null; + } else if (!input.getStackInSlot(3).isOf(NeedsOfNature.ENERGY_DIMINISHER)) { + return null; + } else if (!input.getStackInSlot(5).isOf(NeedsOfNature.ENERGY_DIMINISHER)) { + return null; + } else { + return !input.getStackInSlot(4).isOf(Items.GLOW_BERRIES) ? null : entityTypeId; + } + } else { + return null; + } + } + + private static boolean isCorner(int slot, int width) { + return width != 3 ? false : slot == 0 || slot == 2 || slot == 6 || slot == 8; + } + + private static ItemStack createLiquidDisplayStack() { + RegistryEntry liquidPotion = Registries.POTION.getEntry(NonPotions.LIQUID); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, new PotionContentsComponent(liquidPotion)); + return stack; + } +} diff --git a/src/main/java/com/nonid/recipe/FertileNectarRecipe.java b/src/main/java/com/nonid/recipe/FertileNectarRecipe.java new file mode 100644 index 0000000..89a3327 --- /dev/null +++ b/src/main/java/com/nonid/recipe/FertileNectarRecipe.java @@ -0,0 +1,114 @@ +package com.nonid.recipe; + +import com.nonid.potion.NonPotions; +import java.util.List; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.FoodComponent; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.component.type.FoodComponent.Builder; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.potion.Potion; +import net.minecraft.recipe.CraftingRecipe; +import net.minecraft.recipe.Ingredient; +import net.minecraft.recipe.IngredientPlacement; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; +import net.minecraft.recipe.display.RecipeDisplay; +import net.minecraft.recipe.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.recipe.display.SlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.ItemSlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.StackSlotDisplay; +import net.minecraft.recipe.input.CraftingRecipeInput; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryWrapper.WrapperLookup; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.util.collection.DefaultedList; +import net.minecraft.world.World; + +public final class FertileNectarRecipe implements CraftingRecipe { + private static final FoodComponent FERTILE_NECTAR_FOOD = new Builder().nutrition(7).saturationModifier(0.0F).alwaysEdible().build(); + private final CraftingRecipeCategory category; + private IngredientPlacement ingredientPlacement; + + public FertileNectarRecipe(CraftingRecipeCategory category) { + this.category = category; + } + + public boolean matches(CraftingRecipeInput input, World world) { + int liquid = 0; + int honeyBottle = 0; + int nonEmpty = 0; + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty()) { + nonEmpty++; + if (NonPotions.isLiquidBottle(stack)) { + liquid++; + } else { + if (!stack.isOf(Items.HONEY_BOTTLE)) { + return false; + } + + honeyBottle++; + } + } + } + + return nonEmpty == 2 && liquid == 1 && honeyBottle == 1; + } + + public ItemStack craft(CraftingRecipeInput input, WrapperLookup registries) { + return createPotionStack(NonPotions.FERTILE_NECTAR); + } + + public CraftingRecipeCategory getCategory() { + return this.category; + } + + public IngredientPlacement getIngredientPlacement() { + if (this.ingredientPlacement == null) { + this.ingredientPlacement = IngredientPlacement.forShapeless(List.of(NonRecipeIngredients.liquidBottle(), Ingredient.ofItem(Items.HONEY_BOTTLE))); + } + + return this.ingredientPlacement; + } + + public List getDisplays() { + SlotDisplay liquidDisplay = new StackSlotDisplay(createPotionStack(NonPotions.LIQUID)); + SlotDisplay honeyDisplay = Ingredient.ofItem(Items.HONEY_BOTTLE).toDisplay(); + SlotDisplay resultDisplay = new StackSlotDisplay(createPotionStack(NonPotions.FERTILE_NECTAR)); + return List.of(new ShapelessCraftingRecipeDisplay(List.of(liquidDisplay, honeyDisplay), resultDisplay, new ItemSlotDisplay(Items.CRAFTING_TABLE))); + } + + public DefaultedList getRecipeRemainders(CraftingRecipeInput input) { + DefaultedList remainders = DefaultedList.ofSize(input.size(), ItemStack.EMPTY); + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty() && !NonPotions.isLiquidBottle(stack)) { + remainders.set(i, stack.getItem().getRecipeRemainder()); + } + } + + return remainders; + } + + public RecipeSerializer getSerializer() { + return NonRecipeSerializers.FERTILE_NECTAR_RECIPE; + } + + private static ItemStack createPotionStack(Potion potion) { + RegistryEntry potionEntry = Registries.POTION.getEntry(potion); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, new PotionContentsComponent(potionEntry)); + if (potion == NonPotions.FERTILE_NECTAR) { + stack.set(DataComponentTypes.FOOD, FERTILE_NECTAR_FOOD); + stack.set(DataComponentTypes.MAX_STACK_SIZE, 16); + NonPotions.applyHoneyBottleDrinkSound(stack); + } + + return stack; + } +} diff --git a/src/main/java/com/nonid/recipe/FillAdvancedStraponRecipe.java b/src/main/java/com/nonid/recipe/FillAdvancedStraponRecipe.java new file mode 100644 index 0000000..137e012 --- /dev/null +++ b/src/main/java/com/nonid/recipe/FillAdvancedStraponRecipe.java @@ -0,0 +1,130 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import com.nonid.data.NonAccessoryLiquidFilling; +import com.nonid.potion.NonPotions; +import java.util.List; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.recipe.CraftingRecipe; +import net.minecraft.recipe.Ingredient; +import net.minecraft.recipe.IngredientPlacement; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.book.CraftingRecipeCategory; +import net.minecraft.recipe.display.RecipeDisplay; +import net.minecraft.recipe.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.recipe.display.SlotDisplay.ItemSlotDisplay; +import net.minecraft.recipe.input.CraftingRecipeInput; +import net.minecraft.registry.Registries; +import net.minecraft.registry.RegistryWrapper.WrapperLookup; +import net.minecraft.util.Identifier; +import net.minecraft.util.collection.DefaultedList; +import net.minecraft.world.World; + +public final class FillAdvancedStraponRecipe implements CraftingRecipe { + private static final Identifier ADVANCED_STRAPON_ID = NeedsOfNature.id("strapon_advanced"); + private final CraftingRecipeCategory category; + private IngredientPlacement ingredientPlacement; + + public FillAdvancedStraponRecipe(CraftingRecipeCategory category) { + this.category = category; + } + + public boolean matches(CraftingRecipeInput input, World world) { + ItemStack strapon = ItemStack.EMPTY; + ItemStack liquidBottle = ItemStack.EMPTY; + int nonEmpty = 0; + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (!stack.isEmpty()) { + nonEmpty++; + if (isAdvancedStrapon(stack)) { + if (!strapon.isEmpty()) { + return false; + } + + strapon = stack; + } else { + if (!NonPotions.isLiquidBottle(stack)) { + return false; + } + + if (!liquidBottle.isEmpty()) { + return false; + } + + liquidBottle = stack; + } + } + } + + return nonEmpty == 2 && NonAccessoryLiquidFilling.canFill(strapon, liquidBottle); + } + + public ItemStack craft(CraftingRecipeInput input, WrapperLookup registries) { + ItemStack strapon = ItemStack.EMPTY; + ItemStack liquidBottle = ItemStack.EMPTY; + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (isAdvancedStrapon(stack)) { + strapon = stack; + } else if (NonPotions.isLiquidBottle(stack)) { + liquidBottle = stack; + } + } + + return NonAccessoryLiquidFilling.createFilledCopy(strapon, liquidBottle); + } + + public CraftingRecipeCategory getCategory() { + return this.category; + } + + public IngredientPlacement getIngredientPlacement() { + if (this.ingredientPlacement == null) { + this.ingredientPlacement = IngredientPlacement.forShapeless( + List.of(Ingredient.ofItem(resolveAdvancedStraponItem()), NonRecipeIngredients.liquidBottle()) + ); + } + + return this.ingredientPlacement; + } + + public List getDisplays() { + return List.of( + new ShapelessCraftingRecipeDisplay( + List.of(new ItemSlotDisplay(resolveAdvancedStraponItem()), NonRecipeIngredients.liquidBottle().toDisplay()), + new ItemSlotDisplay(resolveAdvancedStraponItem()), + new ItemSlotDisplay(Items.CRAFTING_TABLE) + ) + ); + } + + public DefaultedList getRecipeRemainders(CraftingRecipeInput input) { + DefaultedList remainders = DefaultedList.ofSize(input.size(), ItemStack.EMPTY); + + for (int i = 0; i < input.size(); i++) { + ItemStack stack = input.getStackInSlot(i); + if (NonPotions.isLiquidBottle(stack)) { + remainders.set(i, new ItemStack(Items.GLASS_BOTTLE)); + } + } + + return remainders; + } + + public RecipeSerializer getSerializer() { + return NonRecipeSerializers.FILL_ADVANCED_STRAPON_RECIPE; + } + + private static boolean isAdvancedStrapon(ItemStack stack) { + return stack != null && !stack.isEmpty() && ADVANCED_STRAPON_ID.equals(Registries.ITEM.getId(stack.getItem())); + } + + private static Item resolveAdvancedStraponItem() { + return (Item)Registries.ITEM.get(ADVANCED_STRAPON_ID); + } +} diff --git a/src/main/java/com/nonid/recipe/NonRecipeIngredients.java b/src/main/java/com/nonid/recipe/NonRecipeIngredients.java new file mode 100644 index 0000000..fbfcc2a --- /dev/null +++ b/src/main/java/com/nonid/recipe/NonRecipeIngredients.java @@ -0,0 +1,84 @@ +package com.nonid.recipe; + +import com.mojang.serialization.MapCodec; +import com.nonid.NeedsOfNature; +import com.nonid.potion.NonPotions; +import java.util.stream.Stream; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredient; +import net.fabricmc.fabric.api.recipe.v1.ingredient.CustomIngredientSerializer; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.PotionContentsComponent; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; +import net.minecraft.potion.Potion; +import net.minecraft.recipe.Ingredient; +import net.minecraft.recipe.display.SlotDisplay; +import net.minecraft.recipe.display.SlotDisplay.StackSlotDisplay; +import net.minecraft.registry.Registries; +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.util.Identifier; + +public final class NonRecipeIngredients { + private NonRecipeIngredients() { + } + + public static void registerAll() { + CustomIngredientSerializer.register(NonRecipeIngredients.LiquidBottleIngredient.SERIALIZER); + } + + public static Ingredient liquidBottle() { + return NonRecipeIngredients.LiquidBottleIngredient.INSTANCE.toVanilla(); + } + + private enum LiquidBottleIngredient implements CustomIngredient { + INSTANCE; + + private static final NonRecipeIngredients.LiquidBottleIngredient.Serializer SERIALIZER = new NonRecipeIngredients.LiquidBottleIngredient.Serializer(); + + public boolean test(ItemStack stack) { + return NonPotions.isLiquidBottle(stack); + } + + public Stream> getMatchingItems() { + return Stream.of(Registries.ITEM.getEntry(Items.POTION)); + } + + public boolean requiresTesting() { + return true; + } + + public CustomIngredientSerializer getSerializer() { + return SERIALIZER; + } + + public SlotDisplay toDisplay() { + RegistryEntry liquidPotion = Registries.POTION.getEntry(NonPotions.LIQUID); + ItemStack stack = new ItemStack(Items.POTION); + stack.set(DataComponentTypes.POTION_CONTENTS, new PotionContentsComponent(liquidPotion)); + return new StackSlotDisplay(stack); + } + + private static final class Serializer implements CustomIngredientSerializer { + private static final Identifier ID = NeedsOfNature.id("liquid_bottle"); + private static final MapCodec CODEC = MapCodec.unit(NonRecipeIngredients.LiquidBottleIngredient.INSTANCE); + private static final PacketCodec PACKET_CODEC = PacketCodec.unit( + NonRecipeIngredients.LiquidBottleIngredient.INSTANCE + ); + + public Identifier getIdentifier() { + return ID; + } + + public MapCodec getCodec() { + return CODEC; + } + + public PacketCodec getPacketCodec() { + return PACKET_CODEC; + } + } + } +} diff --git a/src/main/java/com/nonid/recipe/NonRecipeSerializers.java b/src/main/java/com/nonid/recipe/NonRecipeSerializers.java new file mode 100644 index 0000000..6829499 --- /dev/null +++ b/src/main/java/com/nonid/recipe/NonRecipeSerializers.java @@ -0,0 +1,38 @@ +package com.nonid.recipe; + +import com.nonid.NeedsOfNature; +import net.fabricmc.fabric.api.recipe.v1.sync.RecipeSynchronization; +import net.minecraft.recipe.RecipeSerializer; +import net.minecraft.recipe.SpecialCraftingRecipe.SpecialRecipeSerializer; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; + +public final class NonRecipeSerializers { + public static final RecipeSerializer ENERGY_AUGMENTER_RECIPE = (RecipeSerializer)Registry.register( + Registries.RECIPE_SERIALIZER, NeedsOfNature.id("crafting_energy_augmenter"), new SpecialRecipeSerializer(EnergyAugmenterRecipe::new) + ); + public static final RecipeSerializer ENERGY_DIMINISHER_RECIPE = (RecipeSerializer)Registry.register( + Registries.RECIPE_SERIALIZER, NeedsOfNature.id("crafting_energy_diminisher"), new SpecialRecipeSerializer(EnergyDiminisherRecipe::new) + ); + public static final RecipeSerializer ENERGY_STABILIZER_RECIPE = (RecipeSerializer)Registry.register( + Registries.RECIPE_SERIALIZER, NeedsOfNature.id("crafting_energy_stabilizer"), new SpecialRecipeSerializer(EnergyStabilizerRecipe::new) + ); + public static final RecipeSerializer FERTILE_NECTAR_RECIPE = (RecipeSerializer)Registry.register( + Registries.RECIPE_SERIALIZER, NeedsOfNature.id("crafting_fertile_nectar"), new SpecialRecipeSerializer(FertileNectarRecipe::new) + ); + public static final RecipeSerializer FILL_ADVANCED_STRAPON_RECIPE = (RecipeSerializer)Registry.register( + Registries.RECIPE_SERIALIZER, NeedsOfNature.id("crafting_fill_advanced_strapon"), new SpecialRecipeSerializer(FillAdvancedStraponRecipe::new) + ); + + private NonRecipeSerializers() { + } + + public static void registerAll() { + NonRecipeIngredients.registerAll(); + RecipeSynchronization.synchronizeRecipeSerializer(ENERGY_AUGMENTER_RECIPE); + RecipeSynchronization.synchronizeRecipeSerializer(ENERGY_DIMINISHER_RECIPE); + RecipeSynchronization.synchronizeRecipeSerializer(ENERGY_STABILIZER_RECIPE); + RecipeSynchronization.synchronizeRecipeSerializer(FERTILE_NECTAR_RECIPE); + RecipeSynchronization.synchronizeRecipeSerializer(FILL_ADVANCED_STRAPON_RECIPE); + } +} diff --git a/src/main/java/com/nonid/sound/NonSoundEvents.java b/src/main/java/com/nonid/sound/NonSoundEvents.java new file mode 100644 index 0000000..7530ad0 --- /dev/null +++ b/src/main/java/com/nonid/sound/NonSoundEvents.java @@ -0,0 +1,54 @@ +package com.nonid.sound; + +import com.nonid.NeedsOfNature; +import java.util.Locale; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; + +public final class NonSoundEvents { + public static final SoundEvent DEBUG_STAFF_DISAPPEAR = SoundEvent.of(NeedsOfNature.id("a")); + private static final int DRY_IMPACT_COUNT = 11; + private static final int WET_IMPACT_COUNT = 16; + private static final int WET_COUNT = 12; + private static final int SHOT_IN_COUNT = 3; + private static final int SHOT_OUT_COUNT = 6; + private static final int MOTION_COUNT = 10; + private static final int RETRACT_COUNT = 3; + private static final int RIP_COUNT = 3; + + private NonSoundEvents() { + } + + public static void registerAll() { + registerSeries("impactdry", 11); + registerSeries("impactwet", 16); + registerSeries("wet", 12); + registerSeries("shot_in", 3); + registerSeries("shot_out", 6); + registerSeries("motion", 10); + registerSeries("retract", 3); + registerSeries("rip", 3); + register("a", DEBUG_STAFF_DISAPPEAR); + register("funkytown"); + } + + private static void registerSeries(String prefix, int count) { + for (int i = 1; i <= count; i++) { + String name = prefix + String.format(Locale.ROOT, "%02d", i); + register(name); + } + } + + private static void register(String path) { + register(path, SoundEvent.of(NeedsOfNature.id(path))); + } + + private static void register(String path, SoundEvent soundEvent) { + Identifier id = NeedsOfNature.id(path); + if (!Registries.SOUND_EVENT.containsId(id)) { + Registry.register(Registries.SOUND_EVENT, id, soundEvent); + } + } +} diff --git a/src/main/resources/assets/animationframework/geckolib/models/entity/missingmodel.geo.json b/src/main/resources/assets/animationframework/geckolib/models/entity/missingmodel.geo.json new file mode 100644 index 0000000..11ad5b1 --- /dev/null +++ b/src/main/resources/assets/animationframework/geckolib/models/entity/missingmodel.geo.json @@ -0,0 +1,79 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 16, + "texture_height": 16, + "visible_bounds_width": 3, + "visible_bounds_height": 2.5, + "visible_bounds_offset": [0, 0.75, 0] + }, + "bones": [ + { + "name": "bone", + "pivot": [-7.5, 0, 0] + }, + { + "name": "e", + "parent": "bone", + "pivot": [-8.5, 0, 0], + "cubes": [ + {"origin": [-8.5, 0, -2], "size": [2, 2, 4], "uv": [0, 0]}, + {"origin": [-8.5, 8, -2], "size": [2, 2, 4], "uv": [0, 0]}, + {"origin": [-8.5, 4, -2], "size": [2, 2, 4], "uv": [0, 0]}, + {"origin": [-9.5, 0, -2], "size": [1, 10, 4], "uv": [0, 0]} + ] + }, + { + "name": "r", + "parent": "bone", + "pivot": [-4.5, 9, 0], + "cubes": [ + {"origin": [-4.5, 8, -2], "size": [1.5, 2, 4], "uv": [0, 0]}, + {"origin": [-4.5, 4, -2], "size": [1.5, 2, 4], "uv": [0, 0]}, + {"origin": [-4.5, 8, -2], "size": [4, 1, 4], "pivot": [-3.5, 8, 0], "rotation": [0, 0, 90], "uv": [0, 0]}, + {"origin": [-3, 5, -2], "size": [4, 1, 4], "pivot": [-4, 5, 0], "rotation": [0, 0, 85], "uv": [0, 0]}, + {"origin": [-5.5, 0, -2], "size": [1, 10, 4], "uv": [0, 0]} + ] + }, + { + "name": "r2", + "parent": "bone", + "pivot": [-0.5, 9, 0], + "cubes": [ + {"origin": [-0.5, 8, -2], "size": [1, 2, 4], "uv": [0, 0]}, + {"origin": [-0.5, 4, -2], "size": [1, 2, 4], "uv": [0, 0]}, + {"origin": [-0.5, 8, -2], "size": [4, 1, 4], "pivot": [0.5, 8, 0], "rotation": [0, 0, 90], "uv": [0, 0]}, + {"origin": [1, 5, -2], "size": [4, 1, 4], "pivot": [0, 5, 0], "rotation": [0, 0, 85], "uv": [0, 0]}, + {"origin": [-1.5, 0, -2], "size": [1, 10, 4], "uv": [0, 0]} + ] + }, + { + "name": "r3", + "parent": "bone", + "pivot": [7.5, 9, 0], + "cubes": [ + {"origin": [7.5, 8, -2], "size": [1, 2, 4], "uv": [0, 0]}, + {"origin": [7.5, 4, -2], "size": [1, 2, 4], "uv": [0, 0]}, + {"origin": [7.5, 8, -2], "size": [4, 1, 4], "pivot": [8.5, 8, 0], "rotation": [0, 0, 90], "uv": [0, 0]}, + {"origin": [9, 5, -2], "size": [4, 1, 4], "pivot": [8, 5, 0], "rotation": [0, 0, 85], "uv": [0, 0]}, + {"origin": [6.5, 0, -2], "size": [1, 10, 4], "uv": [0, 0]} + ] + }, + { + "name": "o", + "parent": "bone", + "pivot": [3.5, 0, 0], + "cubes": [ + {"origin": [3, 0, -2], "size": [2, 1, 4], "uv": [0, 0]}, + {"origin": [3, 9, -2], "size": [2, 1, 4], "uv": [0, 0]}, + {"origin": [4.5, 1, -2], "size": [1, 8, 4], "uv": [1, 0]}, + {"origin": [2.5, 1, -2], "size": [1, 8, 4], "uv": [0, 0]} + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/animationframework/textures/missingmodel.png b/src/main/resources/assets/animationframework/textures/missingmodel.png new file mode 100644 index 0000000..bbb93f4 Binary files /dev/null and b/src/main/resources/assets/animationframework/textures/missingmodel.png differ diff --git a/src/main/resources/assets/minecraft/items/lingering_potion.json b/src/main/resources/assets/minecraft/items/lingering_potion.json new file mode 100644 index 0000000..6c8127a --- /dev/null +++ b/src/main/resources/assets/minecraft/items/lingering_potion.json @@ -0,0 +1,90 @@ +{ + "model": { + "type": "minecraft:select", + "property": "minecraft:component", + "component": "minecraft:potion_contents", + "cases": [ + { + "when": "needsofnature:fertile_nectar", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertile_nectar_lingering_potion" + } + }, + { + "when": "needsofnature:fertility_increaser", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_lingering_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_lingering_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_lingering_potion" + } + }, + { + "when": "needsofnature:energy", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_lingering_potion" + } + }, + { + "when": "needsofnature:energy_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_lingering_potion" + } + }, + { + "when": "needsofnature:energy_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_lingering_potion" + } + }, + { + "when": "needsofnature:energy_relief", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_lingering_potion" + } + }, + { + "when": "needsofnature:energy_relief_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_lingering_potion" + } + }, + { + "when": "needsofnature:energy_relief_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_lingering_potion" + } + } + ], + "fallback": { + "type": "minecraft:model", + "model": "minecraft:item/lingering_potion", + "tints": [ + { + "type": "minecraft:potion", + "default": -13083194 + } + ] + } + } +} + diff --git a/src/main/resources/assets/minecraft/items/potion.json b/src/main/resources/assets/minecraft/items/potion.json new file mode 100644 index 0000000..d9eb051 --- /dev/null +++ b/src/main/resources/assets/minecraft/items/potion.json @@ -0,0 +1,97 @@ +{ + "model": { + "type": "minecraft:select", + "property": "minecraft:component", + "component": "minecraft:potion_contents", + "cases": [ + { + "when": "needsofnature:liquid", + "model": { + "type": "minecraft:model", + "model": "minecraft:item/potion" + } + }, + { + "when": "needsofnature:fertile_nectar", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertile_nectar_potion" + } + }, + { + "when": "needsofnature:fertility_increaser", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_potion" + } + }, + { + "when": "needsofnature:energy", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_potion" + } + }, + { + "when": "needsofnature:energy_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_potion" + } + }, + { + "when": "needsofnature:energy_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_potion" + } + }, + { + "when": "needsofnature:energy_relief", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_potion" + } + }, + { + "when": "needsofnature:energy_relief_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_potion" + } + }, + { + "when": "needsofnature:energy_relief_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_potion" + } + } + ], + "fallback": { + "type": "minecraft:model", + "model": "minecraft:item/potion", + "tints": [ + { + "type": "minecraft:potion", + "default": -13083194 + } + ] + } + } +} + diff --git a/src/main/resources/assets/minecraft/items/splash_potion.json b/src/main/resources/assets/minecraft/items/splash_potion.json new file mode 100644 index 0000000..cbdc40e --- /dev/null +++ b/src/main/resources/assets/minecraft/items/splash_potion.json @@ -0,0 +1,90 @@ +{ + "model": { + "type": "minecraft:select", + "property": "minecraft:component", + "component": "minecraft:potion_contents", + "cases": [ + { + "when": "needsofnature:fertile_nectar", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertile_nectar_splash_potion" + } + }, + { + "when": "needsofnature:fertility_increaser", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_splash_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_splash_potion" + } + }, + { + "when": "needsofnature:fertility_increaser_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/fertility_splash_potion" + } + }, + { + "when": "needsofnature:energy", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_splash_potion" + } + }, + { + "when": "needsofnature:energy_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_splash_potion" + } + }, + { + "when": "needsofnature:energy_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_splash_potion" + } + }, + { + "when": "needsofnature:energy_relief", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_splash_potion" + } + }, + { + "when": "needsofnature:energy_relief_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_splash_potion" + } + }, + { + "when": "needsofnature:energy_relief_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_splash_potion" + } + } + ], + "fallback": { + "type": "minecraft:model", + "model": "minecraft:item/splash_potion", + "tints": [ + { + "type": "minecraft:potion", + "default": -13083194 + } + ] + } + } +} + diff --git a/src/main/resources/assets/minecraft/items/tipped_arrow.json b/src/main/resources/assets/minecraft/items/tipped_arrow.json new file mode 100644 index 0000000..80d6f42 --- /dev/null +++ b/src/main/resources/assets/minecraft/items/tipped_arrow.json @@ -0,0 +1,62 @@ +{ + "model": { + "type": "minecraft:select", + "property": "minecraft:component", + "component": "minecraft:potion_contents", + "cases": [ + { + "when": "needsofnature:energy", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_tipped_arrow" + } + }, + { + "when": "needsofnature:energy_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_tipped_arrow" + } + }, + { + "when": "needsofnature:energy_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_tipped_arrow" + } + }, + { + "when": "needsofnature:energy_relief", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_tipped_arrow" + } + }, + { + "when": "needsofnature:energy_relief_ii", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_tipped_arrow" + } + }, + { + "when": "needsofnature:energy_relief_long", + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_relief_tipped_arrow" + } + } + ], + "fallback": { + "type": "minecraft:model", + "model": "minecraft:item/tipped_arrow", + "tints": [ + { + "type": "minecraft:potion", + "default": -13083194 + } + ] + } + } +} + diff --git a/src/main/resources/assets/needsofnature/credits.json b/src/main/resources/assets/needsofnature/credits.json new file mode 100644 index 0000000..b846694 --- /dev/null +++ b/src/main/resources/assets/needsofnature/credits.json @@ -0,0 +1,112 @@ +{ + "notice": "Needs Of Nature is COMPLETELY FREE and should only be downloaded from the official project page. If you paid for it, it was sold by a UNAUTHORIZED THIRD PARTY; please report it on the official project forum.", + "sections": [ + { + "title": "Development", + "contributors": [ + { + "name": "L1Z0", + "roles": [ + "Project Lead", + "Programming", + "Modelling", + "Animating" + ] + } + ] + }, + { + "title": "Contributors", + "contributors": [ + { + "name": "Incarnadine", + "roles": [ + "Game Design", + "Modelling", + "Animating", + "Testing" + ] + }, + { + "name": "PercePerceiver ", + "roles": [ + "Animating", + "Testing" + ] + }, + { + "name": "ParmesanPrime ", + "roles": [ + "Modelling", + "Animating", + "Testing" + ] + }, + { + "name": "Seko", + "roles": [ + "Art", + "Testing" + ] + } + ] + }, + { + "title": "Additional Contributors", + "contributors": [ + { + "name": "ShinyCradily", + "roles": [ + "Testing", + "Modelling help" + ] + }, + { + "name": "ChristmasPuffin", + "roles": [ + "Testing", + "Modelling Help" + ] + }, + { + "name": "Paladin Fox", + "roles": [ + "Testing" + ] + }, + { + "name": "Mowl", + "roles": [ + "Testing" + ] + }, + { + "name": "污绅", + "roles": [ + "Testing" + ] + } + ] + } + ], + "resources": [ + { + "category": "Sound Effects", + "title": "Shinlalala's Lewd Sound Pack", + "creator": "Shinlalala", + "source": "itch.io", + "source_url": "https://shinlalala.itch.io/shinlalalas-lewd-sound-pack", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0/" + }, + { + "category": "Sound Effects", + "title": "Tearing Corduroy Shirt SFX", + "creator": "rupertcole", + "source": "Pixabay", + "source_url": "https://pixabay.com/sound-effects/household-tearing-corduroy-shirt-16734/", + "license": "Pixabay Content License", + "license_url": "https://pixabay.com/service/license-summary/" + } + ] +} diff --git a/src/main/resources/assets/needsofnature/geckolib/animations/afw/debug_spin/debug_spin.animation.json b/src/main/resources/assets/needsofnature/geckolib/animations/afw/debug_spin/debug_spin.animation.json new file mode 100644 index 0000000..420f4b1 --- /dev/null +++ b/src/main/resources/assets/needsofnature/geckolib/animations/afw/debug_spin/debug_spin.animation.json @@ -0,0 +1,20 @@ +{ + "animations": { + "animation": { + "loop": true, + "animation_length": 1, + "bones": { + "fish": { + "rotation": { + "0.0": { + "vector": [0, 0, 0] + }, + "1.0": { + "vector": [0, 360, 0] + } + } + } + } + } + } +} diff --git a/src/main/resources/assets/needsofnature/geckolib/models/entity/horse_liquid_collector.geo.json b/src/main/resources/assets/needsofnature/geckolib/models/entity/horse_liquid_collector.geo.json new file mode 100644 index 0000000..ef0b08c --- /dev/null +++ b/src/main/resources/assets/needsofnature/geckolib/models/entity/horse_liquid_collector.geo.json @@ -0,0 +1,126 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 5, + "visible_bounds_height": 4.5, + "visible_bounds_offset": [0, 1.75, 0] + }, + "bones": [ + { + "name": "dummy", + "pivot": [0, 0, 0] + }, + { + "name": "base", + "parent": "dummy", + "pivot": [0, 0, 0], + "cubes": [ + { + "origin": [-4, 0, -4], + "size": [8, 4, 8], + "uv": { + "north": {"uv": [56, 23], "uv_size": [8, 4]}, + "east": {"uv": [56, 23], "uv_size": [8, 4]}, + "south": {"uv": [56, 23], "uv_size": [8, 4]}, + "west": {"uv": [56, 23], "uv_size": [8, 4]}, + "up": {"uv": [56, 27], "uv_size": [8, 8]}, + "down": {"uv": [56, 35], "uv_size": [8, -8]} + } + } + ] + }, + { + "name": "stand", + "parent": "dummy", + "pivot": [0, 0, 0], + "cubes": [ + { + "origin": [-2, 1, -2], + "size": [4, 10, 4], + "uv": { + "north": {"uv": [56, 35], "uv_size": [4, 10]}, + "east": {"uv": [56, 35], "uv_size": [4, 10]}, + "south": {"uv": [60, 35], "uv_size": [4, 10]}, + "west": {"uv": [60, 35], "uv_size": [4, 10]}, + "up": {"uv": [56, 35], "uv_size": [4, 4]}, + "down": {"uv": [60, 39], "uv_size": [4, -4]} + } + } + ] + }, + { + "name": "collector", + "parent": "dummy", + "pivot": [0, 13.5, -1] + }, + { + "name": "head4", + "parent": "collector", + "pivot": [0, 17.5, -9.5], + "rotation": [22.5, 0, 0] + }, + { + "name": "neck2", + "parent": "head4", + "pivot": [0, 17.5, -9.5], + "cubes": [ + {"origin": [-2.05, 11.5, -10.5], "size": [4, 9, 6], "uv": [25, 23]} + ] + }, + { + "name": "mane2", + "parent": "head4", + "pivot": [0, 17.5, -9.51], + "cubes": [ + {"origin": [-1, 11.5, -4.5], "size": [2, 13, 2], "uv": [36, 39]} + ] + }, + { + "name": "head6", + "parent": "head4", + "pivot": [0, 15.5, -8.5], + "cubes": [ + {"origin": [-3, 20.5, -10.5], "size": [6, 4, 6], "uv": [0, 23]} + ] + }, + { + "name": "left_ear2", + "parent": "head6", + "pivot": [0, 16.5, -8.51], + "cubes": [ + {"origin": [1.55, 24.5, -5.51], "size": [1, 2, 1], "uv": [20, 24]} + ] + }, + { + "name": "right_ear2", + "parent": "head6", + "pivot": [0, 16.5, -8.51], + "cubes": [ + {"origin": [-2.55, 24.5, -5.51], "size": [1, 2, 1], "uv": [19, 24]} + ] + }, + { + "name": "mouth2", + "parent": "head4", + "pivot": [0, 15.5, -8.5], + "cubes": [ + {"origin": [-2, 20.5, -14.5], "size": [4, 4, 4], "uv": [2, 34]} + ] + }, + { + "name": "body4", + "parent": "collector", + "pivot": [0, 9, 7.5], + "cubes": [ + {"origin": [-4, 8, -8.5], "size": [8, 8, 17], "inflate": 0.05, "uv": [2, 39]} + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/needsofnature/geckolib/models/fish.geo.json b/src/main/resources/assets/needsofnature/geckolib/models/fish.geo.json new file mode 100644 index 0000000..cd330d8 --- /dev/null +++ b/src/main/resources/assets/needsofnature/geckolib/models/fish.geo.json @@ -0,0 +1,92 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 32, + "texture_height": 32, + "visible_bounds_width": 3, + "visible_bounds_height": 2.5, + "visible_bounds_offset": [0, 0.75, 0] + }, + "bones": [ + { + "name": "fish", + "pivot": [0, 7.5, 0] + }, + { + "name": "head", + "parent": "fish", + "pivot": [0, 7.5, -8], + "cubes": [ + {"origin": [-1, 5.5, -11], "size": [2, 4, 3], "uv": [22, 0]} + ] + }, + { + "name": "bone", + "parent": "head", + "pivot": [0, 7.5, -8] + }, + { + "name": "body_front", + "parent": "fish", + "pivot": [0, 7.5, -8], + "cubes": [ + {"origin": [-1.5, 5, -8], "size": [3, 5, 8], "uv": [0, 0]} + ] + }, + { + "name": "body_back", + "parent": "fish", + "pivot": [0, 7.5, 0], + "cubes": [ + {"origin": [-1.5, 5, 0], "size": [3, 5, 8], "uv": [0, 13]} + ] + }, + { + "name": "fin_left", + "parent": "fish", + "pivot": [1.5, 6, -8], + "rotation": [0, 0, 45], + "cubes": [ + {"origin": [1.5, 6, -8], "size": [2, 0, 2], "uv": [0, 0]} + ] + }, + { + "name": "fin_right", + "parent": "fish", + "pivot": [-1.5, 6, -8], + "rotation": [0, 0, -45], + "cubes": [ + {"origin": [-3.5, 6, -8], "size": [2, 0, 2], "uv": [-4, 0]} + ] + }, + { + "name": "fin_back_1", + "parent": "fish", + "pivot": [0, 12, -3], + "cubes": [ + {"origin": [0, 10, -3], "size": [0, 2, 3], "uv": [2, 1]} + ] + }, + { + "name": "fin_back_2", + "parent": "fish", + "pivot": [0, 12, -1], + "cubes": [ + {"origin": [0, 10, -1], "size": [0, 2, 4], "uv": [0, 2]} + ] + }, + { + "name": "tail", + "parent": "fish", + "pivot": [0, 7.5, 8], + "cubes": [ + {"origin": [0, 5, 8], "size": [0, 5, 6], "uv": [20, 10]} + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/needsofnature/icon.png b/src/main/resources/assets/needsofnature/icon.png new file mode 100644 index 0000000..75dc279 Binary files /dev/null and b/src/main/resources/assets/needsofnature/icon.png differ diff --git a/src/main/resources/assets/needsofnature/items/debug_staff.json b/src/main/resources/assets/needsofnature/items/debug_staff.json new file mode 100644 index 0000000..b799205 --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/debug_staff.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/stick" + } +} diff --git a/src/main/resources/assets/needsofnature/items/energy_augmenter.json b/src/main/resources/assets/needsofnature/items/energy_augmenter.json new file mode 100644 index 0000000..a0ffa7b --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/energy_augmenter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_augmenter" + } +} diff --git a/src/main/resources/assets/needsofnature/items/energy_diminisher.json b/src/main/resources/assets/needsofnature/items/energy_diminisher.json new file mode 100644 index 0000000..c561158 --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/energy_diminisher.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_diminisher" + } +} diff --git a/src/main/resources/assets/needsofnature/items/energy_stabilizer.json b/src/main/resources/assets/needsofnature/items/energy_stabilizer.json new file mode 100644 index 0000000..79be7d6 --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/energy_stabilizer.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/energy_stabilizer" + } +} diff --git a/src/main/resources/assets/needsofnature/items/flower_mix.json b/src/main/resources/assets/needsofnature/items/flower_mix.json new file mode 100644 index 0000000..8374054 --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/flower_mix.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "needsofnature:item/flower_mix" + } +} diff --git a/src/main/resources/assets/needsofnature/items/horse_liquid_collector.json b/src/main/resources/assets/needsofnature/items/horse_liquid_collector.json new file mode 100644 index 0000000..e8ba8df --- /dev/null +++ b/src/main/resources/assets/needsofnature/items/horse_liquid_collector.json @@ -0,0 +1,10 @@ +{ + "model": { + "type": "minecraft:special", + "base": "needsofnature:item/horse_liquid_collector", + "model": { + "type": "geckolib:geckolib" + } + }, + "hand_animation_on_swap": false +} diff --git a/src/main/resources/assets/needsofnature/lang/en_us.json b/src/main/resources/assets/needsofnature/lang/en_us.json new file mode 100644 index 0000000..f99a8a5 --- /dev/null +++ b/src/main/resources/assets/needsofnature/lang/en_us.json @@ -0,0 +1,895 @@ +{ + "item.needsofnature.debug_staff": "Nature Debug Staff", + "item.needsofnature.flower_mix": "Flower Mix", + "item.needsofnature.energy_augmenter": "Energy Augmenter", + "item.needsofnature.energy_diminisher": "Energy Diminisher", + "item.needsofnature.energy_stabilizer": "Energy Stabilizer", + "item.needsofnature.iron_plug": "Iron Plug", + "item.needsofnature.protector_v": "V Protector", + "item.needsofnature.protector_a": "A Protector", + "item.needsofnature.protector_av": "A/V Protector", + "item.needsofnature.diaphragm": "Diaphragm", + "item.needsofnature.strapon": "Strapon", + "item.needsofnature.strapon_advanced": "Advanced Strapon", + "item.needsofnature.accessory.tooltip.liquid_decay_reduced": "Liquid decay %s", + "item.needsofnature.accessory.tooltip.liquid_decay_increased": "Liquid decay %s", + "item.needsofnature.accessory.tooltip.equalize_liquid_decay_context": "No sneak/water liquid decay increase", + "item.needsofnature.accessory.tooltip.inactive_tank_slot": "Not in liquid tank slot", + "item.needsofnature.accessory.tooltip.player_energy_gain.increased": "Energy buildup %s", + "item.needsofnature.accessory.tooltip.player_energy_gain.reduced": "Energy buildup %s", + "item.needsofnature.accessory.tooltip.liquid_capacity.increased": "Liquid capacity %s ml", + "item.needsofnature.accessory.tooltip.liquid_capacity.reduced": "Liquid capacity %s ml", + "item.needsofnature.accessory.tooltip.liquid_gain.increased": "Liquid gain %s", + "item.needsofnature.accessory.tooltip.liquid_gain.reduced": "Liquid gain %s", + "item.needsofnature.accessory.tooltip.filled_effect.increased": "Filled penalty %s", + "item.needsofnature.accessory.tooltip.filled_effect.reduced": "Filled penalty %s", + "item.needsofnature.accessory.tooltip.pregnancy_chance.increased": "Pregnancy chance %s", + "item.needsofnature.accessory.tooltip.pregnancy_chance.reduced": "Pregnancy chance %s", + "item.needsofnature.accessory.tooltip.pregnancy_duration.increased": "Pregnancy duration %s", + "item.needsofnature.accessory.tooltip.pregnancy_duration.reduced": "Pregnancy duration %s", + "item.needsofnature.accessory.tooltip.mess_gain.increased": "Mess gain %s", + "item.needsofnature.accessory.tooltip.mess_gain.reduced": "Mess gain %s", + "item.needsofnature.accessory.tooltip.destroyed_skin_damage.increased": "Skin damage %s", + "item.needsofnature.accessory.tooltip.destroyed_skin_damage.reduced": "Skin damage %s", + "item.needsofnature.accessory.tooltip.attack_escape_hits.increased": "Escape hits %s", + "item.needsofnature.accessory.tooltip.attack_escape_hits.reduced": "Escape hits %s", + "item.needsofnature.accessory.tooltip.attack_escape_damage.increased": "Escape damage %s", + "item.needsofnature.accessory.tooltip.attack_escape_damage.reduced": "Escape damage %s", + "item.needsofnature.accessory.tooltip.player_energy_aura.increased": "Energy aura %s", + "item.needsofnature.accessory.tooltip.player_energy_aura.reduced": "Energy aura %s", + "item.needsofnature.accessory.tooltip.near_animation_mob_energy.increased": "Nearby animation mob energy %s", + "item.needsofnature.accessory.tooltip.near_animation_mob_energy.reduced": "Nearby animation mob energy %s", + "item.needsofnature.accessory.tooltip.blocks_injector_v": "Blocks V animations", + "item.needsofnature.accessory.tooltip.blocks_injector_a": "Blocks A animations", + "item.needsofnature.accessory.tooltip.blocks_injector_av": "Blocks A/V animations", + "item.needsofnature.accessory.tooltip.occupies_av": "Occupies A and V slots", + "item.needsofnature.accessory.tooltip.ignore_injector_slot_effect_suppression": "Stays active during matching injector animations", + "item.needsofnature.accessory.tooltip.blocks_pregnancy": "Blocks pregnancy", + "item.needsofnature.accessory.tooltip.male_roles": "Enables male animation roles for female players", + "item.needsofnature.accessory.tooltip.no_injected_liquid": "Does not provide liquid on peak", + "item.needsofnature.accessory.tooltip.stored_liquid.empty": "Empty (%s ml capacity)", + "item.needsofnature.accessory.tooltip.stored_liquid.filled": "%s ml of %s", + "item.needsofnature.horse_liquid_collector": "Horse Liquid Collector", + "item.needsofnature.entity_energy_stabilizer": "%s Energy Stabilizer", + "item.needsofnature.mixed_liquid_bottle": "Bottle of Mixed Liquid", + "entity.needsofnature.horse_liquid_collector": "Horse Liquid Collector", + "itemGroup.needsofnature.main": "Needs of Nature", + "trinkets.slot.legs.v": "V", + "trinkets.slot.legs.a": "A", + "trinkets.slot.legs.d": "D", + "key.categories.needsofnature": "Needs Of Nature", + "key.categories.needsofnature.main": "Needs Of Nature", + "key.category.needsofnature.main": "Needs Of Nature", + "key.needsofnature.start_manual_peak": "Start Manual Peak", + "gui.needsofnature.repair_skin": "Repair damaged skin", + "gui.needsofnature.repair_skin.needs_loom": "Needs a loom nearby", + "gui.needsofnature.repair_skin.needs_loom_or_crafting_table": "Needs a loom or crafting table nearby", + "gui.needsofnature.trinket_slot.requires_female": "Requires female gender", + "gui.needsofnature.trinket_slot.requires_male": "Requires male gender", + "gui.needsofnature.trinket_slot.unavailable": "Unavailable", + "config.needsofnature.categories_title": "Needs Of Nature Config", + "config.needsofnature.category.toggles": "Toggles", + "config.needsofnature.category.player_preferences": "Player Preferences", + "config.needsofnature.category.settings": "Settings", + "config.needsofnature.category.credits": "Credits", + "config.needsofnature.category.gameplay": "Gameplay", + "config.needsofnature.category.vanilla_overrides": "Vanilla Overrides", + "config.needsofnature.category.liquid": "Liquid", + "config.needsofnature.category.pregnancy": "Pregnancy", + "config.needsofnature.category.interface": "Interface", + "config.needsofnature.category.intiface": "Buttplug.io", + "config.needsofnature.category.debug_advanced": "Debug & Advanced", + "config.needsofnature.toggles_title": "Needs Of Nature Toggles", + "config.needsofnature.player_preferences_title": "Needs Of Nature Player Preferences", + "config.needsofnature.settings_title": "Needs Of Nature Settings", + "config.needsofnature.credits_title": "Needs Of Nature Credits", + "config.needsofnature.credits_intro": "Needs Of Nature is made possible by:", + "config.needsofnature.credits.resource_credits": "Resource Credits", + "config.needsofnature.credits.project_links": "Project Links", + "config.needsofnature.credits.category_value": "Category: %s", + "config.needsofnature.credits.creator_value": "Creator: %s", + "config.needsofnature.credits.source_value": "Source: %s", + "config.needsofnature.credits.license_value": "License: %s", + "config.needsofnature.credits.license_modified_value": "License: %s | Modified", + "config.needsofnature.credits.assets_value": "Assets: %s", + "config.needsofnature.credits.modified": "Modified", + "config.needsofnature.credits.source": "Source", + "config.needsofnature.credits.license": "License", + "config.needsofnature.gameplay_title": "Needs Of Nature Gameplay", + "config.needsofnature.pregnancy_title": "Needs Of Nature Pregnancy", + "config.needsofnature.liquid_settings_title": "Needs Of Nature Liquid", + "config.needsofnature.debug_advanced_title": "Needs Of Nature Debug & Advanced", + "config.needsofnature.loaded_animations_title": "Loaded Animations", + "config.needsofnature.loaded_animations": "Loaded animations", + "config.needsofnature.loaded_animations.load_order": "Load order...", + "config.needsofnature.loaded_animations.load_order_title": "Animation Pack Load Order", + "config.needsofnature.loaded_animations.load_order.summary": "Top = highest priority. External NoN packs override built-in content.", + "config.needsofnature.loaded_animations.load_order.read_only": "Read-only while connected to a remote server.", + "config.needsofnature.loaded_animations.load_order.base_non": "Built-in NeedsOfNature content (locked)", + "config.needsofnature.loaded_animations.load_order.move_up": "Move pack up", + "config.needsofnature.loaded_animations.load_order.move_down": "Move pack down", + "config.needsofnature.loaded_animations.disabled_count": "%s off", + "config.needsofnature.loaded_animations_search": "Filter by id/entity", + "config.needsofnature.loaded_animations_summary": "Loaded: %s | Shown: %s", + "config.needsofnature.loaded_animations.details": "Details", + "config.needsofnature.loaded_animations.copied_missing": "Copied missing details", + "config.needsofnature.loaded_animations.status.ok": "OK", + "config.needsofnature.loaded_animations.status.partial": "Partial", + "config.needsofnature.loaded_animations.status.missing": "Missing", + "config.needsofnature.ui_title": "Needs Of Nature UI", + "config.needsofnature.vanilla_override_title": "Needs Of Nature Vanilla Override", + "config.needsofnature.initial_energy_max": "Initial energy max (0-200):", + "config.needsofnature.energy_gain_rate": "Energy gain rate (0.1-10x):", + "config.needsofnature.near_animation_energy_gain_mult": "Near-animation energy gain mult (1-10x):", + "config.needsofnature.player_energy_aura_mult_low": "Player energy aura mult (100-149):", + "config.needsofnature.player_energy_aura_mult_mid": "Player energy aura mult (150-199):", + "config.needsofnature.player_energy_aura_mult_high": "Player energy aura mult (200):", + "config.needsofnature.destroyed_skin_aura_mult_stage1": "Destroyed skin aura mult (stage 1):", + "config.needsofnature.destroyed_skin_aura_mult_stage2": "Destroyed skin aura mult (stage 2):", + "config.needsofnature.destroyed_skin_aura_mult_stage3": "Destroyed skin aura mult (stage 3):", + "config.needsofnature.destroyed_skin_aura_mult_stage4": "Destroyed skin aura mult (stage 4):", + "config.needsofnature.normal_damage_destroyed_skin_chance_percent": "Normal damage skin damage chance:", + "config.needsofnature.player_energy_aura_radius_min": "Player energy aura radius at 100:", + "config.needsofnature.player_energy_aura_radius_max": "Player energy aura radius at 200:", + "config.needsofnature.player_energy_aura_pulse_ticks": "Player energy aura pulse ticks:", + "config.needsofnature.scan_budget": "Scan budget/tick (1-512):", + "config.needsofnature.debug_chat_mode": "Debug chat mode:", + "config.needsofnature.debug_spin_mode": "Debug spin mode:", + "config.needsofnature.debug_spin_mode.value": "%s", + "config.needsofnature.convert_male_v_to_a": "Convert male-only V injections to A:", + "config.needsofnature.destroyed_skin_tint": "Destroyed skin tint:", + "config.needsofnature.destroyed_skin_tint.pick": "Pick destroyed skin tint...", + "config.needsofnature.destroyed_skin_tint.pick_title": "Pick Destroyed Skin Tint", + "config.needsofnature.destroyed_skin_tint.use": "Use color", + "config.needsofnature.destroyed_skin_tint.auto": "Auto", + "config.needsofnature.destroyed_skin_tint.no_skin": "Could not load player skin.", + "config.needsofnature.loop_seconds": "Normal stage seconds (5-300):", + "config.needsofnature.peak_loop_seconds": "Peak stage seconds (1-300):", + "config.needsofnature.gender_male": "Global male spawn chance:", + "config.needsofnature.gender_female": "Global female spawn chance:", + "config.needsofnature.gender_both": "Global both-gender spawn chance:", + "config.needsofnature.allow_player_gender_change_anytime": "Allow gender changes anytime:", + "config.needsofnature.require_player_gender_selection_on_join": "Require gender choice on first join:", + "config.needsofnature.allowed_starting_genders": "Allowed starting genders:", + "config.needsofnature.allowed_genders.all": "All", + "config.needsofnature.allowed_genders.male_female": "Male or Female", + "config.needsofnature.allowed_genders.male": "Male only", + "config.needsofnature.allowed_genders.female": "Female only", + "config.needsofnature.allowed_genders.both": "Male+Female only", + "config.needsofnature.allowed_genders.male_both": "Male or Male+Female", + "config.needsofnature.allowed_genders.female_both": "Female or Male+Female", + "config.needsofnature.toggle_label.enabled": "%s: ON", + "config.needsofnature.toggle_label.disabled": "%s: OFF", + "config.needsofnature.player_gender": "Player gender:", + "config.needsofnature.player_gender.male": "Male", + "config.needsofnature.player_gender.female": "Female", + "config.needsofnature.player_gender.both": "Male+Female", + "config.needsofnature.sync_female_gender_mod": "Sync Female Gender Mod:", + "config.needsofnature.destroyed_skin_female_gender_overrides": "Override FGM settings with destroyed profiles:", + "config.needsofnature.destroyed_skin_female_gender_profiles": "Breast settings...", + "config.needsofnature.destroyed_skin_female_gender_profiles_title": "Breast Settings", + "config.needsofnature.destroyed_skin_female_gender_profile.low": "Destroyed stage 0-1", + "config.needsofnature.destroyed_skin_female_gender_profile.high": "Destroyed stage 2-4", + "config.needsofnature.open_non_player_preferences": "Open NoN Player Preferences", + "config.needsofnature.female_gender.customization": "Customization", + "config.needsofnature.female_gender.physics": "Breast Physics", + "config.needsofnature.female_gender.breast_size": "Breast size:", + "config.needsofnature.female_gender.separation": "Separation:", + "config.needsofnature.female_gender.height": "Height:", + "config.needsofnature.female_gender.depth": "Depth:", + "config.needsofnature.female_gender.rotation": "Rotation:", + "config.needsofnature.female_gender.breast_physics": "Breast physics:", + "config.needsofnature.female_gender.dual_physics": "Dual physics:", + "config.needsofnature.female_gender.intensity": "Intensity:", + "config.needsofnature.female_gender.momentum": "Momentum:", + "config.needsofnature.female_gender.preview_unavailable": "Preview unavailable", + "config.needsofnature.armor_shed_effect": "Armor shed effect:", + "config.needsofnature.action_sound_volume": "Action sound volume:", + "config.needsofnature.action_sound_volume.value": "%s%%", + "config.needsofnature.intiface_title": "Needs Of Nature Buttplug.io", + "config.needsofnature.intiface_toy_title": "Toy Settings", + "config.needsofnature.intiface_vibrator_title": "Vibrator Settings", + "config.needsofnature.intiface_stroker_oscillator_title": "Stroker / Oscillator Settings", + "config.needsofnature.intiface.libs.install_title": "Install Buttplug.io Libraries", + "config.needsofnature.intiface_enabled": "Enable Buttplug.io:", + "config.needsofnature.intiface_server_url": "Server URL:", + "config.needsofnature.intiface_auto_connect": "Auto-connect on world join:", + "config.needsofnature.intiface_stop_on_disconnect": "Stop on disconnect:", + "config.needsofnature.intiface_animation_baseline": "Animation baseline:", + "config.needsofnature.intiface_energized_level": "Energized %s base / pulse:", + "config.needsofnature.intiface_reactiveimpact": "Normal impact:", + "config.needsofnature.intiface_peak_reactiveimpact": "Peak impact:", + "config.needsofnature.intiface_cooldown": "Pulse cooldown (ms):", + "config.needsofnature.intiface_latency_compensation": "Latency compensation:", + "config.needsofnature.intiface_connect_scan": "Connect / scan:", + "config.needsofnature.intiface_test_pulse": "Test pulses:", + "config.needsofnature.intiface_toy_settings": "Toy settings", + "config.needsofnature.intiface_vibrator_settings": "Vibrator settings", + "config.needsofnature.intiface_stroker_oscillator_settings": "Stroker / oscillator settings", + "config.needsofnature.intiface_oscillator_regular_speed": "Regular animation speed:", + "config.needsofnature.intiface_oscillator_peak_speed": "Peak stage speed:", + "config.needsofnature.intiface_stroker_max_distance": "Max distance:", + "config.needsofnature.intiface_stroker_min_distance": "Min distance:", + "config.needsofnature.intiface_stroker_regular_duration": "Regular move duration:", + "config.needsofnature.intiface_stroker_peak_duration": "Peak move duration:", + "config.needsofnature.intiface_connect": "Connect", + "config.needsofnature.intiface_scan": "Scan", + "config.needsofnature.intiface_test": "Test", + "config.needsofnature.intiface_disconnect": "Disconnect", + "config.needsofnature.intiface.libs.install": "Install libraries", + "config.needsofnature.intiface.libs.download": "Download", + "config.needsofnature.intiface.libs.redownload": "Redownload libraries", + "config.needsofnature.intiface.libs.restart_required": "Restart required", + "config.needsofnature.intiface.libs.install_line_1": "Buttplug.io support needs optional Java libraries that are not bundled with NoN.", + "config.needsofnature.intiface.libs.install_line_2": "Download size: %s from Maven Central. A Minecraft restart is required.", + "config.needsofnature.intiface.libs.install_line_3": "No server setting controls this; it only affects your local client.", + "config.needsofnature.intiface.status.disabled": "Disabled", + "config.needsofnature.intiface.status.unavailable": "Unavailable", + "config.needsofnature.intiface.status.disconnected": "Disconnected", + "config.needsofnature.intiface.status.connecting": "Connecting", + "config.needsofnature.intiface.status.connected": "Connected (%s devices)", + "config.needsofnature.intiface.status.scanning": "Scanning (%s devices)", + "config.needsofnature.intiface.status.error": "Error (%s devices)", + "config.needsofnature.intiface.status.device_added": "Device added: %s", + "config.needsofnature.intiface.status.device_removed": "Device removed", + "config.needsofnature.intiface.status.scan_finished": "Scan finished", + "config.needsofnature.intiface.status.no_devices": "No devices connected", + "config.needsofnature.intiface.status.no_vibrate_devices": "No vibration-capable devices", + "config.needsofnature.intiface.status.stopped": "Stopped all devices", + "config.needsofnature.intiface.libs.status.available": "Libraries available", + "config.needsofnature.intiface.libs.status.missing": "Libraries missing (%s download)", + "config.needsofnature.intiface.libs.status.outdated": "Libraries outdated; redownload required", + "config.needsofnature.intiface.libs.status.installing": "Installing: %s", + "config.needsofnature.intiface.libs.status.building": "Building library bundle", + "config.needsofnature.intiface.libs.status.restart_required": "Installed, restart Minecraft to activate", + "config.needsofnature.intiface.libs.status.failed": "Install failed: %s", + "config.needsofnature.male_male_chance": "Male+male chance (0-100):", + "config.needsofnature.female_female_chance": "Female+female chance (0-100):", + "config.needsofnature.entity_entity_chance": "Entity+entity chance (0-100):", + "config.needsofnature.multi_actor_join_chance": "Join chance (3+ actors) (0-100):", + "config.needsofnature.section.world": "World", + "config.needsofnature.section.energy": "Energy", + "config.needsofnature.section.animations": "Animations", + "config.needsofnature.section.difficulty": "Difficulty", + "config.needsofnature.section.interface": "Interface", + "config.needsofnature.section.player_identity": "Player identity", + "config.needsofnature.section.local_visuals": "Local visuals", + "config.needsofnature.section.intiface_connection": "Buttplug.io connection", + "config.needsofnature.section.intiface_libraries": "Buttplug.io libraries", + "config.needsofnature.section.intiface_baseline": "Animation baseline", + "config.needsofnature.section.intiface_energized": "Energized heartbeat", + "config.needsofnature.section.intiface_reactiveimpact": "Reactive impact", + "config.needsofnature.section.intiface_controls": "Connection controls", + "config.needsofnature.section.intiface_toy_settings": "Toy settings", + "config.needsofnature.section.intiface_timing": "Timing", + "config.needsofnature.section.intiface_oscillator": "Oscillator", + "config.needsofnature.section.intiface_stroker": "Stroker", + "config.needsofnature.section.mob_energy": "Mob energy", + "config.needsofnature.section.gender_spawn_chances": "Gender spawn chances", + "config.needsofnature.section.player_gender_rules": "Player gender rules", + "config.needsofnature.section.energy_gain": "Energy gain", + "config.needsofnature.section.player_energy_aura": "Player energy aura", + "config.needsofnature.section.destroyed_skin": "Destroyed skin", + "config.needsofnature.section.aura_range_timing": "Aura range & timing", + "config.needsofnature.section.animation_pacing": "Animation pacing", + "config.needsofnature.section.match_chances": "Match chances", + "config.needsofnature.section.player_requests": "Player requests", + "config.needsofnature.section.attack_escape": "Attack escape", + "config.needsofnature.section.player_attack_balance": "Player attack balance", + "config.needsofnature.section.attack_rules": "Attack rules", + "config.needsofnature.section.mount_protection": "Mount protection", + "config.needsofnature.section.last_defeated": "Last defeated", + "config.needsofnature.section.liquid_based_pregnancy": "Liquid-based pregnancy", + "config.needsofnature.section.ripped_skin_repair": "Ripped skin repair", + "config.needsofnature.section.core_systems": "Core systems", + "config.needsofnature.section.death_persistence": "Death persistence", + "config.needsofnature.section.vanilla_integration": "Vanilla integration", + "config.needsofnature.section.breeding": "Breeding", + "config.needsofnature.section.milking": "Milking", + "config.needsofnature.section.pregnancy_timing": "Pregnancy timing", + "config.needsofnature.section.pregnancy_behavior": "Pregnancy behavior", + "config.needsofnature.section.liquid_tank": "Liquid tank", + "config.needsofnature.section.filled_penalties": "Filled penalties", + "config.needsofnature.section.runtime_budget": "Runtime budget", + "config.needsofnature.section.debug_feedback": "Debug feedback", + "config.needsofnature.section.animation_runtime": "Animation behavior", + "config.needsofnature.section.animation_data": "Animation data", + "config.needsofnature.toggle.pregnancy": "Toggle pregnancy:", + "config.needsofnature.toggle.liquid_tank": "Toggle liquid tank:", + "config.needsofnature.toggle.mess_system": "Toggle mess system:", + "config.needsofnature.toggle.destroyed_skin_system": "Toggle destroyed skin system:", + "config.needsofnature.keep_mess_after_death": "Keep mess after death:", + "config.needsofnature.keep_ripped_skin_after_death": "Keep ripped skin after death:", + "config.needsofnature.keep_liquid_tank_after_death": "Keep liquid tank after death:", + "config.needsofnature.keep_pregnancy_after_death": "Keep pregnancy after death:", + "config.needsofnature.allow_crafting_table_skin_repair": "Allow crafting table repair:", + "config.needsofnature.toggle.vanilla_overrides": "Toggle vanilla overrides:", + "config.needsofnature.use_animation_breeding": "Use animation breeding override:", + "config.needsofnature.require_male_female_breeding": "Require male+female for breeding:", + "config.needsofnature.female_cow_only_milking": "Female-cow-only milking:", + "config.needsofnature.player_request_min_energy": "Minimum request energy (0-200):", + "config.needsofnature.attack_hits": "Attack escape hits (1-50):", + "config.needsofnature.attack_decay": "Attack decay/sec (0-5):", + "config.needsofnature.attack_escape_damage_difficulty_percent": "Escape health penalty (0-200%):", + "config.needsofnature.pvp_full_health_escape_bonus_percent": "Full-health escape bonus (0-200%):", + "config.needsofnature.pvp_critical_health_mash_strength_percent": "Critical-health mash strength (0-200%):", + "config.needsofnature.pvp_max_heat_escape_strength_percent": "Maximum-heat escape strength (0-200%):", + "config.needsofnature.pvp_zero_heat_attack_strength_percent": "Zero-heat attack strength (0-200%):", + "config.needsofnature.attack_escape_invulnerability_seconds": "Escape damage invulnerability seconds (0-10):", + "config.needsofnature.attack_escape_animation_protection_seconds": "Escape animation protection seconds (0-10):", + "config.needsofnature.post_escape_gather_max_mobs": "Post-escape chasers (0-64):", + "config.needsofnature.block_animations_while_riding_living_entities": "Protect living mounts:", + "config.needsofnature.block_animations_while_riding_vehicles": "Protect vehicles:", + "config.needsofnature.attack_outcome_failsafe_threshold": "Attack failsafe peaks (1-50):", + "config.needsofnature.attack_creative_players": "Allow attacks on creative players:", + "config.needsofnature.last_defeated_enabled": "Last defeated rescue:", + "config.needsofnature.last_defeated_energy_threshold": "Last defeated energy (0-200):", + "config.needsofnature.last_defeated_search_radius": "Last defeated radius (1-32):", + "config.needsofnature.last_defeated_cooldown_seconds": "Last defeated cooldown seconds:", + "config.needsofnature.liquid_based_pregnancy": "Liquid-based pregnancy:", + "config.needsofnature.pregnancy_risky_start_day": "Risky phase starts (day 1-28):", + "config.needsofnature.pregnancy_ovulation_end_day": "Ovulation ends (day 3-30):", + "config.needsofnature.pregnancy_safe_day_chance_percent": "Safe-day chance multiplier (%):", + "config.needsofnature.pregnancy_risky_chance_percent": "Risky chance multiplier (%):", + "config.needsofnature.pregnancy_ovulation_chance_percent": "Ovulation chance multiplier (%):", + "config.needsofnature.pregnancy_cycle.safe": "Safe day", + "config.needsofnature.pregnancy_cycle.normal": "Normal day", + "config.needsofnature.pregnancy_cycle.risky": "Risky day", + "config.needsofnature.pregnancy_cycle.ovulation": "Ovulating", + "config.needsofnature.pregnancy_chance_percent": "Pregnancy chance (0-100):", + "config.needsofnature.pregnancy_duration_minutes": "Pregnancy duration (minutes):", + "config.needsofnature.pregnancy_egg_hatch_minutes": "Pregnancy egg hatch time (minutes):", + "config.needsofnature.pregnancy_auto_tame_mobs": "Auto-tame tameable offspring:", + "config.needsofnature.pregnancy_friendly_mobs_ignore_players": "Spawned hostiles ignore players:", + "config.needsofnature.offspring_count_button": "Entity pregnancy profiles...", + "config.needsofnature.liquid_tank_capacity": "Liquid tank capacity (ml):", + "config.needsofnature.liquid_decay": "Liquid decay/sec (0-20):", + "config.needsofnature.peak_xp_per_ml": "Peak XP per ml:", + "config.needsofnature.liquid_puddle_despawn_seconds": "Puddle despawn seconds (1-300):", + "config.needsofnature.filled_stage_one_speed": "Filled speed mult (stage 1):", + "config.needsofnature.filled_stage_two_speed": "Filled speed mult (stage 2):", + "config.needsofnature.filled_stage_three_speed": "Filled speed mult (stage 3):", + "config.needsofnature.filled_stage_one_jump": "Filled jump mult (stage 1):", + "config.needsofnature.filled_stage_two_jump": "Filled jump mult (stage 2):", + "config.needsofnature.filled_stage_three_jump": "Filled jump mult (stage 3):", + "config.needsofnature.gender_spawn_button": "Gender spawn...", + "config.needsofnature.gender_spawn_title": "Gender Spawn Chances per Entity", + "config.needsofnature.gender_spawn_add": "Add entry", + "config.needsofnature.gender_spawn_reset": "Reset", + "config.needsofnature.gender_spawn_reset_title": "Reset gender spawn chances?", + "config.needsofnature.gender_spawn_reset_body": "This will replace entity rows with NoNpack defaults.", + "config.needsofnature.gender_spawn_column.entity": "Entity", + "config.needsofnature.gender_spawn_column.male": "Male", + "config.needsofnature.gender_spawn_column.female": "Female", + "config.needsofnature.gender_spawn_column.both": "Both", + "config.needsofnature.gender_spawn_invalid_total": "Total must be 100", + "config.needsofnature.liquid_gain_button": "Liquid gain...", + "config.needsofnature.liquid_gain_title": "Liquid Gain per Entity", + "config.needsofnature.liquid_gain_add": "Add entry", + "config.needsofnature.liquid_gain_reset": "Reset", + "config.needsofnature.liquid_gain_reset_title": "Reset liquid gains?", + "config.needsofnature.liquid_gain_reset_body": "This will replace all entries with defaults.", + "config.needsofnature.liquid_gain_column.entity": "Entity", + "config.needsofnature.liquid_gain_column.ml": "ml", + "config.needsofnature.liquid_gain_column.color": "Color", + "config.needsofnature.offspring_count_title": "Pregnancy Profiles per Entity", + "config.needsofnature.offspring_count_add": "Add entry", + "config.needsofnature.offspring_count_reset": "Reset", + "config.needsofnature.offspring_count_reset_title": "Reset pregnancy profiles?", + "config.needsofnature.offspring_count_reset_body": "This will replace entity rows with NoNpack defaults.", + "config.needsofnature.offspring_count_column.entity": "Entity", + "config.needsofnature.entity_profile_column.chance": "Chance", + "config.needsofnature.offspring_count_column.min": "Min", + "config.needsofnature.offspring_count_column.max": "Max", + "config.needsofnature.entity_profile_column.birth_entity": "Birth entity", + "config.needsofnature.entity_profile_column.birth_mode": "Mode", + "config.needsofnature.entity_profile_column.egg": "Egg", + "config.needsofnature.entity_profile_egg_button": "Egg", + "config.needsofnature.egg_profile_title": "Egg Settings", + "config.needsofnature.egg_profile_start_size": "Start size", + "config.needsofnature.egg_profile_end_size": "End size", + "config.needsofnature.egg_profile_texture": "Texture", + "config.needsofnature.egg_profile_health": "Health", + "config.needsofnature.apply": "Apply", + "config.needsofnature.done": "Done", + "config.needsofnature.done_unsaved": "Leave without save", + "config.needsofnature.reset": "Reset", + "config.needsofnature.reset_icon": "<", + "config.needsofnature.remove_icon": "x", + "config.needsofnature.debug_chat_mode.all": "All", + "config.needsofnature.debug_chat_mode.setup_warnings_errors": "warnings, setp, err", + "config.needsofnature.debug_chat_mode.setup_errors": "setup + errors", + "config.needsofnature.debug_chat_mode.errors_only": "errors", + "config.needsofnature.ui_energy_x": "Energy bar X:", + "config.needsofnature.ui_energy_y": "Energy bar Y:", + "config.needsofnature.ui_attack_x": "Attack bar X:", + "config.needsofnature.ui_attack_y": "Attack bar Y:", + "config.needsofnature.ui_prompt_x": "Attack prompt X:", + "config.needsofnature.ui_prompt_y": "Attack prompt Y:", + "config.needsofnature.ui_liquid_x": "Liquid tank X:", + "config.needsofnature.ui_liquid_y": "Liquid tank Y:", + "config.needsofnature.ui_energy": "Loop progress", + "config.needsofnature.ui_energy_heart": "Energy heart", + "config.needsofnature.ui_attack": "Attack bar", + "config.needsofnature.ui_prompt": "Attack prompt", + "config.needsofnature.ui_liquid": "Liquid tank", + "config.needsofnature.ui_drag_help": "Drag HUD elements to move them. Arrow keys nudge the selected element.", + "config.needsofnature.ui_selected": "Selected: %s", + "config.needsofnature.ui_selected_hidden": "Selected: %s (hidden)", + "config.needsofnature.ui_hidden_label": "%s (hidden)", + "config.needsofnature.ui_hide_selected": "Hide selected", + "config.needsofnature.ui_show_selected": "Show selected", + "config.needsofnature.ui_reset_selected": "Reset selected", + "config.needsofnature.ui_reset_all": "Reset all", + "config.needsofnature.tooltip.ui_toolbar_visibility": "Show or hide the UI editor buttons.", + "config.needsofnature.tooltip.ui_toggle_selected_visibility": "Show or hide the currently selected HUD element.", + "config.needsofnature.tooltip.category.toggles": "Master switches for major NON systems.", + "config.needsofnature.tooltip.category.player_preferences": "Client-owned player preferences such as player gender and destroyed skin tint.", + "config.needsofnature.tooltip.category.settings": "Gameplay, override, liquid, pregnancy, debug, and advanced settings.", + "config.needsofnature.tooltip.category.credits": "View project contributors, resource credits, and project links.", + "config.needsofnature.tooltip.category.gameplay": "Energy, matching, and escape behavior settings.", + "config.needsofnature.tooltip.category.vanilla_overrides": "Override vanilla breeding and milking behavior.", + "config.needsofnature.tooltip.category.liquid": "Liquid gain, decay, bottle, and filled-effect settings.", + "config.needsofnature.tooltip.category.pregnancy": "Pregnancy chance, duration, and egg hatch settings.", + "config.needsofnature.tooltip.category.interface": "Move NON HUD elements for better alignment.", + "config.needsofnature.tooltip.category.intiface": "Optional local Buttplug.io toy-control integration for your own animation events.", + "config.needsofnature.tooltip.category.debug_advanced": "Debug output and performance-oriented advanced options.", + "config.needsofnature.tooltip.category.disabled_in_toggles": "Disabled in Toggles.", + "config.needsofnature.tooltip.toggle.pregnancy": "Globally enable or disable pregnancy mechanics.", + "config.needsofnature.tooltip.toggle.liquid_tank": "Globally enable or disable player liquid tank mechanics.", + "config.needsofnature.tooltip.toggle.mess_system": "Globally enable or disable mess gain, cleaning, and visible mess overlays.", + "config.needsofnature.tooltip.toggle.destroyed_skin_system": "Globally enable or disable destroyed skin damage, repair, manual shearing, and visible destroyed skin overlays.", + "config.needsofnature.tooltip.toggle.vanilla_overrides": "Globally enable or disable all NON vanilla override mechanics.", + "config.needsofnature.tooltip.initial_energy_max": "Maximum random energy assigned to mobs when they first initialize.", + "config.needsofnature.tooltip.energy_gain_rate": "Global multiplier for natural mob energy gain over time.", + "config.needsofnature.tooltip.near_animation_energy_gain_mult": "Extra energy gain multiplier when a mob sees a nearby active animation.", + "config.needsofnature.tooltip.player_energy_aura_mult_low": "When player energy is 100-149, nearby viable mobs gain energy as if their natural gain rate were multiplied by this amount.", + "config.needsofnature.tooltip.player_energy_aura_mult_mid": "When player energy is 150-199, nearby viable mobs gain energy as if their natural gain rate were multiplied by this amount.", + "config.needsofnature.tooltip.player_energy_aura_mult_high": "When player energy is 200, nearby viable mobs gain energy as if their natural gain rate were multiplied by this amount.", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage1": "Extra multiplier applied to the player energy aura while the player has destroyed skin stage 1.", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage2": "Extra multiplier applied to the player energy aura while the player has destroyed skin stage 2.", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage3": "Extra multiplier applied to the player energy aura while the player has destroyed skin stage 3.", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage4": "Extra multiplier applied to the player energy aura while the player has destroyed skin stage 4.", + "config.needsofnature.tooltip.normal_damage_destroyed_skin_chance_percent": "Percent chance for accepted non-animation player damage to add one destroyed skin damage.", + "config.needsofnature.tooltip.player_energy_aura_radius_min": "Block radius used for the player energy aura at exactly 100 energy.", + "config.needsofnature.tooltip.player_energy_aura_radius_max": "Block radius used for the player energy aura at 200 energy.", + "config.needsofnature.tooltip.player_energy_aura_pulse_ticks": "How often the player energy aura applies bonus mob energy, in ticks.", + "config.needsofnature.tooltip.loop_seconds": "Default seconds before auto-advancing looping non-peak stages. Stage duration multipliers scale this value per stage.", + "config.needsofnature.tooltip.peak_loop_seconds": "Default seconds before auto-advancing looping peak stages. Stage duration multipliers scale this value per stage.", + "config.needsofnature.tooltip.gender_male": "Default percentage chance that a newly initialized mob receives only the male gender tag. Global male/female/both values must total 100.", + "config.needsofnature.tooltip.gender_female": "Default percentage chance that a newly initialized mob receives only the female gender tag. Global male/female/both values must total 100.", + "config.needsofnature.tooltip.gender_both": "Default percentage chance that a newly initialized mob receives both gender tags. Global male/female/both values must total 100.", + "config.needsofnature.tooltip.allow_player_gender_change_anytime": "If disabled, players cannot freely change their NON gender after it has been selected or assigned.", + "config.needsofnature.tooltip.require_player_gender_selection_on_join": "If enabled, players without a saved NON gender must choose one when joining.", + "config.needsofnature.tooltip.allowed_starting_genders": "Controls which genders are available in the first-join gender selection screen.", + "config.needsofnature.tooltip.player_gender": "Your own gender used for animation eligibility and player .m/.f model selection.", + "config.needsofnature.tooltip.player_gender.locked": "Gender changes are disabled by this server.", + "config.needsofnature.tooltip.sync_female_gender_mod": "Keeps Female Gender Mod's local player gender matched to your NON player gender. Male+Female syncs as Other.", + "config.needsofnature.tooltip.destroyed_skin_female_gender_overrides": "Overrides Female Gender Mod breast settings with the profile for your current destroyed stage. Turning this off disables the profile controls, stops future writes, and leaves the latest written values.", + "config.needsofnature.tooltip.destroyed_skin_female_gender_profiles": "Configure the Female Gender Mod breast settings NON writes for destroyed stages 0-1 and 2-4.", + "config.needsofnature.tooltip.female_gender.breast_texture_editor": "Open Female Gender Mod's shared breast texture editor.", + "config.needsofnature.tooltip.wildfire_gender_controlled_by_non": "Change gender in Needs Of Nature player preferences.", + "config.needsofnature.tooltip.wildfire_personalization_controlled_by_non": "Change breast settings in Needs Of Nature.", + "config.needsofnature.tooltip.open_non_player_preferences": "Open Needs Of Nature player preferences.", + "config.needsofnature.tooltip.intiface_enabled": "Enables local toy-control through Buttplug.io. Disabled by default.", + "config.needsofnature.tooltip.intiface_server_url": "WebSocket URL of the local Buttplug.io server, usually ws://127.0.0.1:12345.", + "config.needsofnature.tooltip.intiface_auto_connect": "Automatically connects to Buttplug.io when joining a world.", + "config.needsofnature.tooltip.intiface_stop_on_disconnect": "Sends a stop command to connected devices when leaving a world.", + "config.needsofnature.tooltip.intiface_animation_baseline": "Low vibration while you are in an animation. Reactive impacts temporarily spike above it. Set 0% to disable.", + "config.needsofnature.tooltip.intiface_energized": "Base vibration while Energized is active, plus heartbeat peak strength synced to the heart pulse. Left slider is base, right slider is pulse peak.", + "config.needsofnature.tooltip.intiface_reactiveimpact": "Strength slider and duration in milliseconds for normal reactive impact cues.", + "config.needsofnature.tooltip.intiface_peak_reactiveimpact": "Strength slider and duration in milliseconds for peak reactive impact cues.", + "config.needsofnature.tooltip.intiface_cooldown": "Minimum milliseconds between pulses. Use 0 to allow every matching cue.", + "config.needsofnature.tooltip.intiface_latency_compensation": "Sends predictable animation and heartbeat toy commands this many milliseconds early. Animation starts cannot be anticipated before they are received; stop and disconnect commands remain immediate.", + "config.needsofnature.tooltip.intiface_status": "Current local Buttplug.io connection state and device count.", + "config.needsofnature.tooltip.intiface_libraries_status": "Whether the optional Buttplug.io support libraries are installed and loaded.", + "config.needsofnature.tooltip.intiface_libraries_install": "Downloads verified libraries from Maven Central and creates a generated library jar in your mods folder. Requires restart.", + "config.needsofnature.tooltip.intiface_connect_scan": "Connect to Buttplug.io and scan for devices.", + "config.needsofnature.tooltip.intiface_test_pulse": "Send a manual test pulse using the configured normal impact profile.", + "config.needsofnature.tooltip.intiface_toy_settings": "Open toy response settings for vibrators, strokers, and oscillators.", + "config.needsofnature.tooltip.intiface_vibrator_settings": "Configure vibrator baseline and reactive impact pulses.", + "config.needsofnature.tooltip.intiface_stroker_oscillator_settings": "Configure stroker and oscillator responses.", + "config.needsofnature.tooltip.intiface_oscillator_regular_speed": "Oscillator speed while you are in a regular animation stage. Set 0% to disable.", + "config.needsofnature.tooltip.intiface_oscillator_peak_speed": "Oscillator speed while you are in a peak animation stage. Set 0% to disable.", + "config.needsofnature.tooltip.intiface_stroker_max_distance": "Stroker position used immediately on reactive impact cues.", + "config.needsofnature.tooltip.intiface_stroker_min_distance": "Stroker position targeted halfway before the next reactive impact cue.", + "config.needsofnature.tooltip.intiface_stroker_regular_duration": "Milliseconds used by the stroker API when moving to max or min on regular reactive impact cues. Set 0ms to disable.", + "config.needsofnature.tooltip.intiface_stroker_peak_duration": "Milliseconds used by the stroker API when moving to max or min on peak reactive impact cues. Set 0ms to disable.", + "config.needsofnature.tooltip.female_gender.range": "Female Gender Mod value range: %s.", + "config.needsofnature.tooltip.female_gender.breast_size_extended": "Values above 100% might look broken and clip under certain conditions.", + "config.needsofnature.tooltip.female_gender.breast_physics": "Enables or disables Female Gender Mod breast physics for this destroyed-stage profile.", + "config.needsofnature.tooltip.female_gender.dual_physics": "ON uses separate left/right physics. OFF writes Female Gender Mod's uniboob setting.", + "config.needsofnature.tooltip.armor_shed_effect": "Client-side visual effect that shows equipped armor flying off when you enter a fresh animation.", + "config.needsofnature.tooltip.action_sound_volume": "Scales NON action sounds resolved from animation sound cues. Minecraft's normal sound category sliders still apply.", + "config.needsofnature.tooltip.male_male_chance": "Roll chance for same-gender male pairings to proceed.", + "config.needsofnature.tooltip.female_female_chance": "Roll chance for same-gender female pairings to proceed.", + "config.needsofnature.tooltip.entity_entity_chance": "Roll chance for any mob+mob animation attempt before matching.", + "config.needsofnature.tooltip.multi_actor_join_chance": "Roll chance for joining when total actors would become 3+.", + "config.needsofnature.tooltip.use_animation_breeding": "If enabled, in-love animals try to breed through NON animations before spawning a child.", + "config.needsofnature.tooltip.require_male_female_breeding": "If enabled, animation breeding only allows one male and one female parent.", + "config.needsofnature.tooltip.require_male_female_breeding.disabled_animation_breeding": "Disabled because animation breeding is off.", + "config.needsofnature.tooltip.female_cow_only_milking": "If enabled, only female (or both-gender) cows can be milked with buckets.", + "config.needsofnature.tooltip.keep_mess_after_death": "If enabled, player mess values survive death and respawn.", + "config.needsofnature.tooltip.keep_ripped_skin_after_death": "If enabled, ripped skin damage and stage survive death and respawn.", + "config.needsofnature.tooltip.keep_liquid_tank_after_death": "If enabled, liquid amount and composition survive death and respawn.", + "config.needsofnature.tooltip.keep_pregnancy_after_death": "If enabled, pregnancy state survives death and continues after respawn.", + "config.needsofnature.tooltip.allow_crafting_table_skin_repair": "If enabled, crafting tables can also repair ripped clothing. Looms always work.", + "config.needsofnature.tooltip.player_request_min_energy": "Minimum player energy required to request a new animation or join an existing one.", + "config.needsofnature.tooltip.attack_hits": "Base A/D hits needed to escape an attack animation.", + "config.needsofnature.tooltip.attack_decay": "How quickly escape progress decays per second while not pressing.", + "config.needsofnature.tooltip.attack_escape_damage_difficulty_percent": "How strongly low health reduces escape progress against mobs. 0% disables the health penalty, 100% is normal, 200% is harsher.", + "config.needsofnature.tooltip.pvp_full_health_escape_bonus_percent": "Extra PvP escape progress gained between 75% and full health. At 30%, a full-health defender has 1.30x mash strength.", + "config.needsofnature.tooltip.pvp_critical_health_mash_strength_percent": "PvP mash strength at zero health, rising to normal strength at 25% health. Applies equally to attackers and defenders.", + "config.needsofnature.tooltip.pvp_max_heat_escape_strength_percent": "PvP defender mash strength at maximum energy. Energy up to 60% has no escape penalty.", + "config.needsofnature.tooltip.pvp_zero_heat_attack_strength_percent": "PvP attacker mash strength at zero energy, rising to normal strength at maximum energy.", + "config.needsofnature.tooltip.attack_escape_invulnerability_seconds": "How long the player is protected from normal damage after escape damage is applied.", + "config.needsofnature.tooltip.attack_escape_animation_protection_seconds": "How long new animations are blocked after escaping an attack animation.", + "config.needsofnature.tooltip.post_escape_gather_max_mobs": "Maximum nearby 70+ energy mobs that immediately start chasing after a successful attack escape. Set to 0 to disable.", + "config.needsofnature.tooltip.block_animations_while_riding_living_entities": "If enabled, automatic NoN mob-player starts are skipped while the player is riding a living mount such as a horse, pig, or strider.", + "config.needsofnature.tooltip.block_animations_while_riding_vehicles": "If enabled, automatic NoN mob-player starts are skipped while the player is riding a non-living vehicle such as a boat or minecart.", + "config.needsofnature.tooltip.attack_outcome_failsafe_threshold": "How many peaked attack animations a mob can have in 10 minutes before its energy is locked to 0 for the failsafe cooldown.", + "config.needsofnature.tooltip.attack_creative_players": "If enabled, NoN mob and player attacks may target creative players.", + "config.needsofnature.tooltip.last_defeated_enabled": "If enabled, lethal mob damage can leave the player at half a heart and start a defeated animation instead of death.", + "config.needsofnature.tooltip.last_defeated_energy_threshold": "Minimum mob energy required to trigger last defeated rescue.", + "config.needsofnature.tooltip.last_defeated_search_radius": "Nearby mob search radius used if the damaging mob cannot trigger a valid rescue.", + "config.needsofnature.tooltip.last_defeated_cooldown_seconds": "Cooldown after a rescue before the same player can be rescued again.", + "config.needsofnature.tooltip.pregnancy_chance_percent": "Chance per peaked animation normally, or chance per minute at a full tank when liquid-based pregnancy is enabled.", + "config.needsofnature.tooltip.liquid_based_pregnancy": "Use stored liquid and the player's 30-day cycle instead of rolling pregnancy directly at a peak. Mixed liquid randomly uses one of its three most recent non-player donors.", + "config.needsofnature.tooltip.pregnancy_risky_start_day": "First Risky day of the 30-day pregnancy cycle.", + "config.needsofnature.tooltip.pregnancy_ovulation_end_day": "Last Ovulation day. Ovulation always occupies this day and the preceding day.", + "config.needsofnature.tooltip.pregnancy_safe_day_chance_percent": "Pregnancy chance multiplier during the Safe phase. 10% equals 0.1x.", + "config.needsofnature.tooltip.pregnancy_risky_chance_percent": "Pregnancy chance multiplier during the Risky phase. 300% equals 3x.", + "config.needsofnature.tooltip.pregnancy_ovulation_chance_percent": "Pregnancy chance multiplier during the two-day Ovulation phase. 500% equals 5x.", + "gui.needsofnature.pregnancy_cycle.tooltip": "Day %s/%s\n%s", + "config.needsofnature.tooltip.pregnancy_duration_minutes": "How many real-time minutes pregnancy lasts before hatch-ready.", + "config.needsofnature.tooltip.pregnancy_egg_hatch_minutes": "Default hatch time in minutes for pregnancy egg entities.", + "config.needsofnature.tooltip.pregnancy_auto_tame_mobs": "If enabled, tameable pregnancy offspring are automatically tamed or bonded to the player that spawned them.", + "config.needsofnature.tooltip.pregnancy_friendly_mobs_ignore_players": "If enabled, hostile pregnancy offspring are prevented from targeting any player.", + "config.needsofnature.tooltip.offspring_count_button": "Open the per-entity pregnancy profile editor.", + "config.needsofnature.tooltip.liquid_tank_capacity": "Maximum amount of liquid the player tank can hold.", + "config.needsofnature.tooltip.liquid_decay": "How many ml are drained per second from the player liquid tank.", + "config.needsofnature.tooltip.peak_xp_per_ml": "Experience spawned per XP-eligible ml after a peaked animation. Default is 0.0667, equal to 1 XP per 15 ml.", + "config.needsofnature.tooltip.liquid_puddle_despawn_seconds": "Lifetime in seconds of spawned liquid puddle particles.", + "config.needsofnature.tooltip.filled_stage_one_speed": "Movement speed multiplier while in Filled stage 1.", + "config.needsofnature.tooltip.filled_stage_two_speed": "Movement speed multiplier while in Filled stage 2.", + "config.needsofnature.tooltip.filled_stage_three_speed": "Movement speed multiplier while in Filled stage 3.", + "config.needsofnature.tooltip.filled_stage_one_jump": "Jump multiplier while in Filled stage 1.", + "config.needsofnature.tooltip.filled_stage_two_jump": "Jump multiplier while in Filled stage 2.", + "config.needsofnature.tooltip.filled_stage_three_jump": "Jump multiplier while in Filled stage 3.", + "config.needsofnature.tooltip.liquid_gain_button": "Open the per-entity liquid gain and color override editor.", + "config.needsofnature.tooltip.gender_spawn_button": "Open the per-entity gender spawn chance editor. Existing mobs keep their current gender.", + "config.needsofnature.tooltip.scan_budget": "Max mob scan operations per world tick before skipping additional scans.", + "config.needsofnature.tooltip.debug_chat_mode": "Controls which NON debug chat categories are shown.", + "config.needsofnature.tooltip.debug_spin_mode": "Client-side visual override: replace animations with simple spin debug clips.", + "config.needsofnature.tooltip.convert_male_v_to_a": "If enabled, male-only players may use V-injector animations and treat those peaks as A for tank and mess behavior.", + "config.needsofnature.tooltip.animation.auto_switch_third_person": "Temporarily switch to third-person view while the local player is in an animation.", + "config.needsofnature.tooltip.destroyed_skin_tint": "Hex RGB tint used for the generated destroyed skin fallback. Leave empty to auto-detect from your skin.", + "config.needsofnature.tooltip.destroyed_skin_tint.pick": "Open your full skin texture and click a pixel to use its color.", + "config.needsofnature.tooltip.destroyed_skin_tint.custom_skin": "Disabled because a custom destroyed_skin.png is active. Custom destroyed skins ignore the generated skin tint.", + "config.needsofnature.tooltip.destroyed_skin_tint.disabled_system": "Disabled because the Destroyed Skin System is off in Toggles.", + "config.needsofnature.tooltip.destroyed_skin_tint.default_skin": "Disabled because the selected default skin has its own destroyed skin texture. Default destroyed skins ignore the generated skin tint.", + "config.needsofnature.tooltip.destroyed_skin_system.disabled_setting": "Disabled because the Destroyed Skin System is off in Toggles.", + "config.needsofnature.tooltip.destroyed_skin_tint.auto": "Clear the manual tint so NON auto-detects it from your skin again.", + "config.needsofnature.tooltip.loaded_animations": "View all loaded animation definitions and runtime GeckoLib resource status.", + "config.needsofnature.tooltip.loaded_animations.load_order": "Reorder external NoN packs. Changes auto-reload while in a world.", + "config.needsofnature.tooltip.loaded_animations.load_order.reset": "Reset external NoN pack order to default alphabetical order.", + "config.needsofnature.tooltip.loaded_animations.load_order.move_up": "Move this pack one position higher.", + "config.needsofnature.tooltip.loaded_animations.load_order.move_down": "Move this pack one position lower.", + "config.needsofnature.tooltip.liquid_gain_add": "Add a new entity liquid row.", + "config.needsofnature.tooltip.liquid_gain_reset": "Reset all liquid gain/color rows to defaults.", + "config.needsofnature.tooltip.gender_spawn_add": "Add a new entity gender spawn row.", + "config.needsofnature.tooltip.gender_spawn_reset": "Reset entity gender spawn rows to NoNpack defaults.", + "config.needsofnature.tooltip.offspring_count_add": "Add a new entity pregnancy profile row.", + "config.needsofnature.tooltip.offspring_count_reset": "Reset pregnancy profile rows to NoNpack defaults.", + "config.needsofnature.tooltip.gender_spawn_entry_entity": "Entity id, for example: minecraft:wolf", + "config.needsofnature.tooltip.gender_spawn_entry_male": "Male-only spawn percentage. Row is ignored unless male+female+both totals exactly 100.", + "config.needsofnature.tooltip.gender_spawn_entry_female": "Female-only spawn percentage. Row is ignored unless male+female+both totals exactly 100.", + "config.needsofnature.tooltip.gender_spawn_entry_both": "Both-gender spawn percentage. Row is ignored unless male+female+both totals exactly 100.", + "config.needsofnature.tooltip.liquid_gain_entry_entity": "Entity id, for example: minecraft:wolf", + "config.needsofnature.tooltip.liquid_gain_entry_ml": "Liquid gain in ml granted for this entity when used as injector.", + "config.needsofnature.tooltip.liquid_gain_entry_color": "Optional entity override color in hex (#RRGGBB).", + "config.needsofnature.tooltip.offspring_count_entry_entity": "Entity id, for example: minecraft:wolf", + "config.needsofnature.tooltip.entity_profile_entry_chance": "Pregnancy chance override for this entity. Leave empty to use the shown default/global value.", + "config.needsofnature.tooltip.offspring_count_entry_min": "Minimum offspring count. Values are clamped to 1-16.", + "config.needsofnature.tooltip.offspring_count_entry_max": "Maximum offspring count. Values below min are raised to min.", + "config.needsofnature.tooltip.entity_profile_entry_birth_entity": "Entity id to spawn from this pregnancy. Empty uses the source entity.", + "config.needsofnature.tooltip.entity_profile_entry_birth_mode": "Birth mode: egg or direct.", + "config.needsofnature.tooltip.entity_profile_entry_egg": "Open egg settings for this source entity.", + "config.needsofnature.tooltip.egg_profile_start_size": "Egg render and hitbox scale at spawn.", + "config.needsofnature.tooltip.egg_profile_end_size": "Egg render and hitbox scale at hatch time.", + "config.needsofnature.tooltip.egg_profile_texture": "Optional full texture id, for example needsofnature:textures/entity/pregnancy_egg/default.png.", + "config.needsofnature.tooltip.egg_profile_health": "Egg health in raw Minecraft health points.", + "config.needsofnature.tooltip.egg_profile_reset": "Reset these egg fields to the current NoNpack defaults.", + "config.needsofnature.tooltip.apply": "Apply changes without leaving this screen.", + "config.needsofnature.tooltip.done": "Close this screen and return.", + "config.needsofnature.tooltip.done_save": "Save changes and return.", + "config.needsofnature.tooltip.done_unsaved": "Leave without saving unsaved changes.", + "config.needsofnature.tooltip.reset": "Reset this value to its default.", + "config.needsofnature.tooltip.remove": "Remove this row.", + "screen.needsofnature.warning.proceed": "I understand, proceed", + "screen.needsofnature.warning.proceed_countdown": "Wait %s seconds...", + "screen.needsofnature.warning.close_game": "Close Game", + "screen.needsofnature.warning.dont_show_again": "Don't show this warning again", + "screen.needsofnature.warning.pack": "From NoNpack: %s", + "effect.needsofnature.energy": "Energy", + "effect.needsofnature.energy_relief": "Energy Relief", + "effect.needsofnature.energized": "Energized", + "effect.needsofnature.fertility_increaser": "Fertility Increaser", + "effect.needsofnature.filled": "Filled", + "effect.needsofnature.pregnant": "Pregnant", + "item.minecraft.potion.effect.needsofnature.energy": "Potion of Energy", + "item.minecraft.splash_potion.effect.needsofnature.energy": "Splash Potion of Energy", + "item.minecraft.lingering_potion.effect.needsofnature.energy": "Lingering Potion of Energy", + "item.minecraft.tipped_arrow.effect.needsofnature.energy": "Arrow of Energy", + "item.minecraft.potion.effect.needsofnature.energy_ii": "Potion of Energy II", + "item.minecraft.splash_potion.effect.needsofnature.energy_ii": "Splash Potion of Energy II", + "item.minecraft.lingering_potion.effect.needsofnature.energy_ii": "Lingering Potion of Energy II", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_ii": "Arrow of Energy II", + "item.minecraft.potion.effect.needsofnature.energy_long": "Potion of Energy", + "item.minecraft.splash_potion.effect.needsofnature.energy_long": "Splash Potion of Energy", + "item.minecraft.lingering_potion.effect.needsofnature.energy_long": "Lingering Potion of Energy", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_long": "Arrow of Energy", + "item.minecraft.potion.effect.needsofnature.energy_relief": "Potion of Energy Relief", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief": "Splash Potion of Energy Relief", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief": "Lingering Potion of Energy Relief", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief": "Arrow of Energy Relief", + "item.minecraft.potion.effect.needsofnature.energy_relief_ii": "Potion of Energy Relief II", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief_ii": "Splash Potion of Energy Relief II", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief_ii": "Lingering Potion of Energy Relief II", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief_ii": "Arrow of Energy Relief II", + "item.minecraft.potion.effect.needsofnature.energy_relief_long": "Potion of Energy Relief", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief_long": "Splash Potion of Energy Relief", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief_long": "Lingering Potion of Energy Relief", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief_long": "Arrow of Energy Relief", + "item.minecraft.potion.effect.needsofnature.fertile_nectar": "Fertile nectar", + "item.minecraft.splash_potion.effect.needsofnature.fertile_nectar": "Splash Bottle of Fertile Nectar", + "item.minecraft.lingering_potion.effect.needsofnature.fertile_nectar": "Lingering Bottle of Fertile Nectar", + "item.minecraft.tipped_arrow.effect.needsofnature.fertile_nectar": "Arrow of Fertile Nectar", + "item.minecraft.potion.effect.needsofnature.fertility_increaser": "Potion of Fertility Increaser", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser": "Splash Potion of Fertility Increaser", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser": "Lingering Potion of Fertility Increaser", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser": "Arrow of Fertility Increaser", + "item.minecraft.potion.effect.needsofnature.fertility_increaser_ii": "Potion of Fertility Increaser II", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser_ii": "Splash Potion of Fertility Increaser II", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser_ii": "Lingering Potion of Fertility Increaser II", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser_ii": "Arrow of Fertility Increaser II", + "item.minecraft.potion.effect.needsofnature.fertility_increaser_long": "Potion of Fertility Increaser", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser_long": "Splash Potion of Fertility Increaser", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser_long": "Lingering Potion of Fertility Increaser", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser_long": "Arrow of Fertility Increaser", + "screen.needsofnature.gender_selection.title": "Choose Your NON Gender", + "screen.needsofnature.gender_selection.permanent": "This server does not allow free gender changes later.", + "screen.needsofnature.gender_selection.changeable": "This choice can be changed later in Player Preferences.", + "screen.needsofnature.gender_selection.subtitle": "Choose how NON should treat your player.", + "screen.needsofnature.gender_selection.option.male": "♂ Male", + "screen.needsofnature.gender_selection.option.female": "♀ Female", + "screen.needsofnature.gender_selection.option.both": "♂♀ Male+Female", + "screen.needsofnature.gender_selection.current": "%s (current)", + "debug.needsofnature.prefix": "[NoN] %s", + "debug.needsofnature.join_stage_disallows": "Current animation stage is not accepting joins right now.", + "debug.needsofnature.mob_wants_join": "Mob %s wants to join your animation (energy %s, anim %s).", + "debug.needsofnature.join_request_accepted": "Join request accepted for mob %s (anim %s).", + "debug.needsofnature.join_request_denied": "Join request denied for mob %s.", + "debug.needsofnature.player_join_accepted": "Joined animation (anim %s).", + "debug.needsofnature.player_join_failed": "Could not join animation (anim %s).", + "debug.needsofnature.player_join_no_expanded_match": "Could not join: no matching expanded animation.", + "debug.needsofnature.player_join_denied_busy": "Could not join: you are already in an animation.", + "debug.needsofnature.starting_animation_with_mob_200": "Starting animation with mob %s (200 energy).", + "debug.needsofnature.gathering_with_mob": "Gathering with mob %s.", + "debug.needsofnature.mounted_animation_blocked": "Animation skipped: you are riding %s.", + "debug.needsofnature.mob_pair_attempt": "%s attempts to gather with %s (energy %s/%s, anim %s).", + "debug.needsofnature.mob_pair_gathering": "Gathering queued: %s with %s (anim %s).", + "debug.needsofnature.post_escape_gather_started": "%s nearby mobs start chasing after your escape.", + "debug.needsofnature.gather_cancelled": "Gather cancelled: %s (%s).", + "debug.needsofnature.mob_join_no_expanded_match": "%s could not join: no matching expanded animation.", + "debug.needsofnature.roll_result": "Roll %s (%s%%) -> %s.", + "debug.needsofnature.energy_locked_attack_cooldown": "%s is on attack cooldown; energy is locked to 0.", + "debug.needsofnature.attack_blocked_pregnancy_friendly": "Blocked direct attack %s -> %s (pregnancy-friendly mob).", + "debug.needsofnature.attack_blocked_gathering_pair": "Blocked direct attack %s -> %s (both actors are pending/active).", + "debug.needsofnature.attack_blocked_energy_busy": "Blocked direct attack %s -> %s (mob has energy > 70 while player is animation-busy).", + "debug.needsofnature.no_defeat_tag_animation": "No defeat-tag animation matched for player; skipping defeated queue.", + "debug.needsofnature.missing_entity_bottle_fallback": "Missing bottle mapping for %s liquid; using mixed bottle.", + "debug.needsofnature.missing_birth_animation": "No birth animation found for %s; pregnancy was cleared.", + "debug.needsofnature.missing_birth_cue": "Birth animation %s has no 'birth' cue; using stop fallback.", + "debug.needsofnature.birth_fallback_spawn": "Birth fallback spawn triggered on stop (%s).", + "debug.needsofnature.missing_pregnancy_entity_type": "Missing entity type %s for pregnancy spawn.", + "item.needsofnature.entity_liquid_bottle": "Bottle of %s Liquid", + "item.minecraft.potion.effect.needsofnature.liquid": "Bottle of Liquid", + "stat.needsofnature.animations_started_total": "NoN: Animations Started", + "stat.needsofnature.peaked_animations_total": "NoN: Peaked Animations", + "stat.needsofnature.liquid_gained_ml_total": "NoN: Liquid Gained (ml)", + "stat.needsofnature.multi_actor_animations_total": "NoN: 3+ Actor Animations", + "stat.needsofnature.water_animations_total": "NoN: Water Animations", + "stat.needsofnature.attack_animations_started_total": "NoN: Attack Animations Started", + "stat.needsofnature.attack_escapes_success_total": "NoN: Attack Escapes", + "stat.needsofnature.m_injector_peaks_ml_total": "NoN: Injector Type M Peaks (ml)", + "stat.needsofnature.manual_peaks_total": "NoN: Manual Peaks", + "stat.needsofnature.bottles_filled_total": "NoN: Bottles Filled", + "stat.needsofnature.pregnancies_started_total": "NoN: Pregnancies Started", + "stat.needsofnature.pregnancies_completed_total": "NoN: Pregnancies Completed", + "stat.needsofnature.offspring_spawned_total": "NoN: Offspring Spawned", + "advancements.needsofnature.root.title": "Needs of Nature", + "advancements.needsofnature.root.description": "Explore the systems of Needs of Nature.", + "advancements.needsofnature.natural_curiosity.title": "Natural Curiosity", + "advancements.needsofnature.natural_curiosity.description": "Reach the peak stage of any NoN animation.", + "advancements.needsofnature.filled_to_the_brim.title": "Filled to the Brim", + "advancements.needsofnature.filled_to_the_brim.description": "Completely fill your liquid tank.", + "advancements.needsofnature.favouritism.title": "Favouritism", + "advancements.needsofnature.favouritism.description": "Fill your tank completely with liquid from only one entity type.", + "advancements.needsofnature.first_pregnancy.title": "First Pregnancy", + "advancements.needsofnature.first_pregnancy.description": "Begin your first pregnancy.", + "advancements.needsofnature.mother_nature.title": "Mother Nature", + "advancements.needsofnature.mother_nature.description": "Complete 25 pregnancies.", + "advancements.needsofnature.messed_up.title": "Messed Up", + "advancements.needsofnature.messed_up.description": "Reach maximum mess of every type at the same time.", + "advancements.needsofnature.group_activity.title": "Group Activity", + "advancements.needsofnature.group_activity.description": "Take part in an animation with four or more actors.", + "advancements.needsofnature.tossed_around.title": "Tossed Around", + "advancements.needsofnature.tossed_around.description": "Get thrown into five defeated animations in a row.", + "advancements.needsofnature.collectors_habit.title": "Collector's Habit", + "advancements.needsofnature.collectors_habit.description": "Fill 25 bottles from your liquid tank.", + "advancements.needsofnature.field_research.title": "Field Research", + "advancements.needsofnature.field_research.description": "Gain liquid from five different entity types in one in-game day.", + "advancements.needsofnature.bad_idea_beacon.title": "Bad Idea Beacon", + "advancements.needsofnature.bad_idea_beacon.description": "Have full player energy while your skin is fully destroyed.", + "advancements.needsofnature.productive_pregnancy.title": "Productive Pregnancy", + "advancements.needsofnature.productive_pregnancy.description": "Complete a pregnancy that produces more than one offspring or egg.", + "advancements.needsofnature.udderly_unfortunate.title": "Udderly Unfortunate", + "advancements.needsofnature.udderly_unfortunate.description": "Drink milk while pregnant.", + "advancements.needsofnature.emergency_snack.title": "Emergency Snack", + "advancements.needsofnature.emergency_snack.description": "Receive an injector type M hunger reward while hungry, or drink an entity liquid bottle.", + "advancements.needsofnature.stable_diet.title": "Stable Diet", + "advancements.needsofnature.stable_diet.description": "Trigger Emergency Snack three times in a row without eating or drinking anything else.", + "advancements.needsofnature.acquire_hardware.title": "Acquire Hardware", + "advancements.needsofnature.acquire_hardware.description": "Start a manual peak animation with a held item.", + "gui.needsofnature.player_profiles.title": "Player Interaction Profiles", + "gui.needsofnature.player_profiles.tooltip": "Set your default and per-player animation request preferences.", + "gui.needsofnature.player_profiles.default": "Default for other players", + "gui.needsofnature.player_profiles.player_name": "Player name", + "gui.needsofnature.player_profiles.player_name_hint": "Known server player name", + "gui.needsofnature.player_profiles.apply": "Apply", + "gui.needsofnature.player_profiles.unknown_player": "That player is not known to this server yet.", + "gui.needsofnature.player_profiles.allowed_profiles": "Allowed Profiles", + "gui.needsofnature.player_profiles.allowed_profiles_hint": "At least one profile must remain available.", + "gui.needsofnature.player_profiles.allowed": "%s: Allowed", + "gui.needsofnature.player_profiles.disallowed": "%s: Disabled", + "gui.needsofnature.player_profiles.mode.deny_all": "No requests or attacks", + "gui.needsofnature.player_profiles.mode.requests_only": "Requests, no attacks", + "gui.needsofnature.player_profiles.mode.requests_and_attacks": "Requests and attacks", + "gui.needsofnature.player_profiles.mode.auto_accept": "Auto-accept requests", + "gui.needsofnature.player_request.select_title": "Select Animation", + "gui.needsofnature.player_request.select_for": "Request with %s", + "gui.needsofnature.player_request.select_join_title": "Select Join Animation", + "gui.needsofnature.player_request.select_join_for": "Select how to join %s", + "gui.needsofnature.player_request.section.available": "Available animations", + "gui.needsofnature.player_request.section.unavailable": "Unavailable animations", + "gui.needsofnature.player_request.role_title": "Select Your Role", + "gui.needsofnature.player_request.role_option": "Your role: %s (%s)", + "gui.needsofnature.player_request.role_activity.active": "Active", + "gui.needsofnature.player_request.role_activity.passive": "Passive", + "gui.needsofnature.player_request.roles": "%s role options", + "gui.needsofnature.player_request.consent_title": "Animation Request", + "gui.needsofnature.player_request.accept": "Accept", + "gui.needsofnature.player_request.reject": "Reject", + "gui.needsofnature.player_request.start_prompt": "Requests animation: %s", + "gui.needsofnature.player_request.join_prompt": "Requests to join with: %s", + "gui.needsofnature.player_request.waiting_title": "Request Sent", + "gui.needsofnature.player_request.waiting_description": "Waiting for all players to accept...", + "gui.needsofnature.player_request.waiting": "Waiting for player consent.", + "gui.needsofnature.player_request.started": "Animation started.", + "gui.needsofnature.player_request.rejected": "The animation request was rejected.", + "gui.needsofnature.player_request.cancelled": "The animation request was cancelled.", + "gui.needsofnature.player_request.timeout": "The animation request timed out.", + "gui.needsofnature.player_request.out_of_range": "The players moved too far apart.", + "gui.needsofnature.player_request.damaged": "The animation request was cancelled by damage.", + "gui.needsofnature.player_request.disconnected": "A participating player disconnected.", + "gui.needsofnature.player_request.player_busy": "A player is already busy with another animation.", + "gui.needsofnature.player_request.recipient_busy": "A requested player already has another pending request.", + "gui.needsofnature.player_request.not_accepting": "Player is not accepting requests.", + "gui.needsofnature.player_request.energy_low": "You need at least %s energy to request an animation.", + "gui.needsofnature.player_request.already_pending": "You already have a pending animation request.", + "gui.needsofnature.player_request.no_match": "No eligible animation matches these actors.", + "gui.needsofnature.player_request.block_requirement_unavailable": "Required blocks unavailable", + "gui.needsofnature.player_request.block_requirement_summary": "Requires: %s", + "gui.needsofnature.player_request.block_requirement_tooltip": "This animation cannot start here because its block requirement is unavailable: %s", + "gui.needsofnature.player_request.animation_changed": "The selected animation is no longer eligible.", + "gui.needsofnature.player_request.protected": "An equipped protector blocked the animation.", + "gui.needsofnature.player_request.invalid": "The animation request is no longer valid.", +"key.categories.needsofnature.animation": "NeedsOfNature Animation Debug", + "key.category.needsofnature.animation": "NeedsOfNature Animation Debug", + "key.needsofnature.animation.select_actor": "Select actor (debug)", + "key.needsofnature.animation.select_self": "Select self (debug)", + "key.needsofnature.animation.start_debug_animation": "Start animation (debug)", + "key.needsofnature.animation.stop_all_debug": "Stop all animations (debug)", + "key.needsofnature.animation.stop_instance_debug": "Stop animation instance (debug)", + "key.needsofnature.animation.next_stage_debug": "Next stage (debug)", + "config.needsofnature.animation.title": "NeedsOfNature Animations", + "config.needsofnature.animation.force_vanilla_entity_textures": "Force vanilla entity textures (ignore resource packs)", + "config.needsofnature.animation.block_search_radius": "Block search radius", + "config.needsofnature.animation.auto_switch_third_person": "Auto switch to third person on animation start", + "config.needsofnature.animation.debug_menu": "Debug options", + "config.needsofnature.animation.open": "Open", + "config.needsofnature.animation.debug_title": "NeedsOfNature Animation Debug", + "config.needsofnature.animation.debug_chat_mode": "Debug chat", + "config.needsofnature.animation.debug_chat_mode.all": "All", + "config.needsofnature.animation.debug_chat_mode.setup_warnings_errors": "warnings, setup, errors", + "config.needsofnature.animation.debug_chat_mode.setup_errors": "setup, errors", + "config.needsofnature.animation.debug_chat_mode.errors_only": "errors", + "config.needsofnature.animation.reset_icon": "<", + "key.needsofnature.animation.speed_up_debug": "Speed up animation (debug)", + + "key.needsofnature.animation.speed_down_debug": "Slow down animation (debug)", + "debug.needsofnature.animation.stop_all_broadcast_queued": "[NoN Animation] Stop-all broadcast queued.", + "debug.needsofnature.animation.server_eval": "[NoN Animation] Server eval: actors=%s chosen=%s keys=%s", + "debug.needsofnature.animation.start_received": "[NoN Animation] START %s actors=%s keys=%s", + "debug.needsofnature.animation.stop_all_received": "[NoN Animation] STOP ALL", + "debug.needsofnature.animation.stop_instance_received": "[NoN Animation] STOP instance", + "debug.needsofnature.animation.server_cannot_receive_stop_all": "[NoN Animation] Server can't receive stop-all packets (mod not installed server-side).", + "debug.needsofnature.animation.stop_all_requested": "[NoN Animation] Stop-all requested.", + "debug.needsofnature.animation.no_active_instance_to_stop": "[NoN Animation] No active instance to stop.", + "debug.needsofnature.animation.server_cannot_receive_stop_instance": "[NoN Animation] Server can't receive stop-instance packets (mod not installed server-side).", + "debug.needsofnature.animation.stop_instance_requested": "[NoN Animation] Stop instance requested.", + "debug.needsofnature.animation.no_entity_targeted": "[NoN Animation] No entity targeted.", + "debug.needsofnature.animation.target_not_living": "[NoN Animation] Target is not a living entity.", + "debug.needsofnature.animation.target_not_mob": "[NoN Animation] Target is not a mob.", + "debug.needsofnature.animation.animation_deselected": "[NoN Animation] Animation deselected.", + "debug.needsofnature.animation.animation_selected_add_actors_for_join": "[NoN Animation] Animation selected. Add extra actors then press start to join.", + "debug.needsofnature.animation.entity_deselected_actor_count": "[NoN Animation] entity:%s deselected, actors %s", + "debug.needsofnature.animation.entity_selected_actor_count": "[NoN Animation] entity:%s selected, actors %s", + "debug.needsofnature.animation.self_deselected_actor_count": "[NoN Animation] self deselected, actors %s", + "debug.needsofnature.animation.self_selected_actor_count": "[NoN Animation] self selected, actors %s", + "debug.needsofnature.animation.no_actors_selected": "[NoN Animation] No actors selected.", + "debug.needsofnature.animation.no_extra_actors_for_join": "[NoN Animation] No extra actors selected for join.", + "debug.needsofnature.animation.server_cannot_receive_join": "[NoN Animation] Server can't receive join packets (mod not installed server-side).", + "debug.needsofnature.animation.join_requested_instance_extras": "[NoN Animation] Join requested. instance=%s extras=%s", + "debug.needsofnature.animation.server_cannot_receive_debug_packets": "[NoN Animation] Server can't receive debug packets (mod not installed server-side).", + "debug.needsofnature.animation.start_requested_actor_count": "[NoN Animation] Start requested (H). actors=%s", + "debug.needsofnature.animation.no_active_instance_to_advance": "[NoN Animation] No active instance to advance.", + "debug.needsofnature.animation.next_stage_requested_server": "[NoN Animation] Next stage requested (server).", + "debug.needsofnature.animation.next_stage_requested_local": "[NoN Animation] Next stage requested (local only).", + "debug.needsofnature.animation.no_active_instance_to_adjust_speed": "[NoN Animation] No active instance to adjust speed.", + "debug.needsofnature.animation.speed_change_requested": "[NoN Animation] Speed change requested (%s).", + "debug.needsofnature.animation.server_cannot_receive_speed": "[NoN Animation] Server can't receive speed packets (mod missing server-side).", + "debug.needsofnature.animation.stage_sequence_ended_server_stop_unavailable": "[NoN Animation] Stage sequence ended locally; server stop unavailable.", + "debug.needsofnature.animation.queue_enqueue_failed_exactly_one_player": "[NoN Animation] Queue enqueue failed: exactly one player actor is required.", + "debug.needsofnature.animation.queue_enqueue_failed_full": "[NoN Animation] Queue enqueue failed: queue is full (cap=%s).", + "debug.needsofnature.animation.queue_enqueue_failed_player_missing": "[NoN Animation] Queue enqueue failed: player actor is missing.", + "debug.needsofnature.animation.queue_enqueue_denied_mob_already_queued": "[NoN Animation] Queue enqueue denied: %s is already queued for another player.", + "debug.needsofnature.animation.queued_at_position": "[NoN Animation] Queued %s at position %s.", + "debug.needsofnature.animation.queue_cleared_for_player": "[NoN Animation] Queue cleared for player.", + "debug.needsofnature.animation.queue_cleared_for_active_instance": "[NoN Animation] Queue cleared for active instance.", + "debug.needsofnature.animation.matched_unpathable_actor": "[NoN Animation] Matched %s but an actor can't pathfind (not a mob/player).", + "debug.needsofnature.animation.matched_multiple_players_not_supported": "[NoN Animation] Matched %s but multiple players are not supported yet.", + "debug.needsofnature.animation.matched_multiplayer_direct_start": "[NoN Animation] Matched %s with actors %s -> direct multiplayer debug start.", + "debug.needsofnature.animation.start_cancelled_block_and_water_requirement": "[NoN Animation] Start cancelled: block_requirements cannot be combined with water requirement.", + "debug.needsofnature.animation.start_cancelled_no_valid_wall": "[NoN Animation] Start cancelled: no valid wall placement found for %s (actors: %s).", + "debug.needsofnature.animation.start_cancelled_no_valid_center_support": "[NoN Animation] Start cancelled: no valid center support placement found for %s (actors: %s).", + "debug.needsofnature.animation.start_cancelled_players_too_far_from_requester": "[NoN Animation] Start cancelled: selected players must be within %s blocks of requester.", + "debug.needsofnature.animation.join_failed_no_active_instance": "[NoN Animation] Join failed: no such active instance.", + "debug.needsofnature.animation.join_denied_stage_disallows": "[NoN Animation] Join denied: current stage does not allow joins.", + "debug.needsofnature.animation.join_failed_players_not_supported": "[NoN Animation] Join failed: player actors not supported for joins.", + "debug.needsofnature.animation.join_failed_no_expanded_definition": "[NoN Animation] Join failed: no definition for expanded actor set.", + "debug.needsofnature.animation.no_active_instance_to_adjust": "[NoN Animation] No active instance to adjust.", + "debug.needsofnature.animation.speed_unchanged": "[NoN Animation] Speed unchanged.", + "debug.needsofnature.animation.speed_set": "[NoN Animation] Speed set to %s", + "debug.needsofnature.animation.no_such_active_instance": "[NoN Animation] No such active instance.", + "debug.needsofnature.animation.instance_in_different_world": "[NoN Animation] Instance is in a different world; can't stop from here.", + "debug.needsofnature.animation.broadcasting_stop": "[NoN Animation] Broadcasting STOP", + "debug.needsofnature.animation.queue_continued_for_player": "[NoN Animation] Queue continued for player.", + "debug.needsofnature.animation.queue_cleared": "[NoN Animation] Queue cleared.", + "debug.needsofnature.animation.start_cancelled_water_requirement_not_met": "[NoN Animation] Start cancelled: water requirement not met.", + "debug.needsofnature.animation.start_cancelled_not_allowed_in_water": "[NoN Animation] Start cancelled: animation not allowed in water.", + "debug.needsofnature.animation.start_cancelled_not_enough_space": "[NoN Animation] Start cancelled: not enough space for %s (actors: %s).", + "debug.needsofnature.animation.actors_immediate_broadcast_start": "[NoN Animation] Immediate start. Broadcasting START", + "debug.needsofnature.animation.queue_skipped_player_unavailable": "[NoN Animation] Queue skipped for %s (actors: %s): player unavailable.", + "debug.needsofnature.animation.queue_skipped_missing_non_player_actor": "[NoN Animation] Queue skipped for %s (actors: %s): missing non-player actor.", + "debug.needsofnature.animation.queue_skipped_no_actors_resolved": "[NoN Animation] Queue skipped for %s (actors: %s): no actors resolved.", + "debug.needsofnature.animation.queue_skipped_missing_definition": "[NoN Animation] Queue skipped for %s (actors: %s): missing definition.", + "debug.needsofnature.animation.queue_skipped_requirements_not_met": "[NoN Animation] Queue skipped for %s (actors: %s): requirements not met.", + "debug.needsofnature.animation.queue_skipped_start_failed": "[NoN Animation] Queue skipped for %s (actors: %s): start failed.", + "debug.needsofnature.animation.queued_animation_started": "[NoN Animation] Queued animation started: %s (actors: %s)", + "config.jade.plugin_needsofnature.gender": "Mob gender", + "config.needsofnature.server_settings.no_permission": "You do not have permission to change server settings.", + "config.needsofnature.server_settings.stale": "Server settings changed while this screen was open. Reopen it and try again.", + "config.needsofnature.server_settings.invalid": "The server rejected the settings update." +} diff --git a/src/main/resources/assets/needsofnature/lang/zh_cn.json b/src/main/resources/assets/needsofnature/lang/zh_cn.json new file mode 100644 index 0000000..def4651 --- /dev/null +++ b/src/main/resources/assets/needsofnature/lang/zh_cn.json @@ -0,0 +1,901 @@ +{ + "item.needsofnature.debug_staff": "自然调试法杖", + "item.needsofnature.flower_mix": "花粉混合物", + "item.needsofnature.energy_augmenter": "性欲增幅剂", + "item.needsofnature.energy_diminisher": "性欲抑制剂", + "item.needsofnature.energy_stabilizer": "性欲锁定器", + "item.needsofnature.iron_plug": "铁制肛塞", + "item.needsofnature.protector_v": "阴道防护器", + "item.needsofnature.protector_a": "肛门防护器", + "item.needsofnature.protector_av": "阴道/肛门防护器", + "item.needsofnature.diaphragm": "避孕隔膜", + "item.needsofnature.strapon": "穿戴式假阳具", + "item.needsofnature.strapon_advanced": "高级穿戴式假阳具", + "item.needsofnature.accessory.tooltip.liquid_decay_reduced": "体液流失 %s", + "item.needsofnature.accessory.tooltip.liquid_decay_increased": "体液流失 %s", + "item.needsofnature.accessory.tooltip.equalize_liquid_decay_context": "潜行/水中不再额外增加体液流失", + "item.needsofnature.accessory.tooltip.inactive_tank_slot": "不在体液储槽内", + "item.needsofnature.accessory.tooltip.player_energy_gain.increased": "性欲增长 %s", + "item.needsofnature.accessory.tooltip.player_energy_gain.reduced": "性欲增长 %s", + "item.needsofnature.accessory.tooltip.liquid_capacity.increased": "体液容量 %s ml", + "item.needsofnature.accessory.tooltip.liquid_capacity.reduced": "体液容量 %s ml", + "item.needsofnature.accessory.tooltip.liquid_gain.increased": "体液增加 %s", + "item.needsofnature.accessory.tooltip.liquid_gain.reduced": "体液增加 %s", + "item.needsofnature.accessory.tooltip.filled_effect.increased": "灌满惩罚 %s", + "item.needsofnature.accessory.tooltip.filled_effect.reduced": "灌满惩罚 %s", + "item.needsofnature.accessory.tooltip.pregnancy_chance.increased": "受孕概率 %s", + "item.needsofnature.accessory.tooltip.pregnancy_chance.reduced": "受孕概率 %s", + "item.needsofnature.accessory.tooltip.pregnancy_duration.increased": "妊娠时长 %s", + "item.needsofnature.accessory.tooltip.pregnancy_duration.reduced": "妊娠时长 %s", + "item.needsofnature.accessory.tooltip.mess_gain.increased": "污渍增加 %s", + "item.needsofnature.accessory.tooltip.mess_gain.reduced": "污渍增加 %s", + "item.needsofnature.accessory.tooltip.destroyed_skin_damage.increased": "皮肤破损 %s", + "item.needsofnature.accessory.tooltip.destroyed_skin_damage.reduced": "皮肤破损 %s", + "item.needsofnature.accessory.tooltip.attack_escape_hits.increased": "挣脱次数 %s", + "item.needsofnature.accessory.tooltip.attack_escape_hits.reduced": "挣脱次数 %s", + "item.needsofnature.accessory.tooltip.attack_escape_damage.increased": "挣脱伤害 %s", + "item.needsofnature.accessory.tooltip.attack_escape_damage.reduced": "挣脱伤害 %s", + "item.needsofnature.accessory.tooltip.player_energy_aura.increased": "性欲光环 %s", + "item.needsofnature.accessory.tooltip.player_energy_aura.reduced": "性欲光环 %s", + "item.needsofnature.accessory.tooltip.near_animation_mob_energy.increased": "附近动画生物性欲 %s", + "item.needsofnature.accessory.tooltip.near_animation_mob_energy.reduced": "附近动画生物性欲 %s", + "item.needsofnature.accessory.tooltip.blocks_injector_v": "阻止 V 类动画", + "item.needsofnature.accessory.tooltip.blocks_injector_a": "阻止 A 类动画", + "item.needsofnature.accessory.tooltip.blocks_injector_av": "阻止 A/V 类动画", + "item.needsofnature.accessory.tooltip.occupies_av": "占用 A 和 V 槽位", + "item.needsofnature.accessory.tooltip.ignore_injector_slot_effect_suppression": "匹配注入动画期间保持生效", + "item.needsofnature.accessory.tooltip.blocks_pregnancy": "阻止怀孕", + "item.needsofnature.accessory.tooltip.male_roles": "允许女性玩家扮演男性动画角色", + "item.needsofnature.accessory.tooltip.no_injected_liquid": "高潮时不产生体液", + "item.needsofnature.accessory.tooltip.stored_liquid.empty": "空(容量 %s ml)", + "item.needsofnature.accessory.tooltip.stored_liquid.filled": "%s ml 的 %s", + "item.needsofnature.horse_liquid_collector": "马精收集器", + "item.needsofnature.entity_energy_stabilizer": "%s 性欲锁定器", + "item.needsofnature.mixed_liquid_bottle": "混合体液瓶", + "entity.needsofnature.horse_liquid_collector": "马精收集器", + "itemGroup.needsofnature.main": "自然需求", + "trinkets.slot.legs.v": "V", + "trinkets.slot.legs.a": "A", + "trinkets.slot.legs.d": "D", + "key.categories.needsofnature": "自然需求", + "key.categories.needsofnature.main": "自然需求", + "key.category.needsofnature.main": "自然需求", + "key.needsofnature.start_manual_peak": "开始手动高潮", + "gui.needsofnature.repair_skin": "修复破损皮肤", + "gui.needsofnature.repair_skin.needs_workbench": "附近需要织布机", + "gui.needsofnature.trinket_slot.requires_female": "需要女性性别", + "gui.needsofnature.trinket_slot.requires_male": "需要男性性别", + "gui.needsofnature.trinket_slot.unavailable": "不可用", + "config.needsofnature.categories_title": "自然需求配置", + "config.needsofnature.category.toggles": "开关", + "config.needsofnature.category.player_preferences": "玩家偏好", + "config.needsofnature.category.settings": "设置", + "config.needsofnature.category.gameplay": "玩法", + "config.needsofnature.category.vanilla_overrides": "原版覆盖", + "config.needsofnature.category.liquid": "体液", + "config.needsofnature.category.pregnancy": "怀孕", + "config.needsofnature.category.interface": "界面", + "config.needsofnature.category.intiface": "Buttplug.io", + "config.needsofnature.category.debug_advanced": "调试与高级", + "config.needsofnature.toggles_title": "自然需求开关", + "config.needsofnature.player_preferences_title": "自然需求玩家偏好", + "config.needsofnature.settings_title": "自然需求设置", + "config.needsofnature.gameplay_title": "自然需求玩法", + "config.needsofnature.pregnancy_title": "自然需求怀孕", + "config.needsofnature.liquid_settings_title": "自然需求体液", + "config.needsofnature.debug_advanced_title": "自然需求调试与高级", + "config.needsofnature.loaded_animations_title": "已加载动画", + "config.needsofnature.loaded_animations": "已加载动画", + "config.needsofnature.loaded_animations.load_order": "加载顺序...", + "config.needsofnature.loaded_animations.load_order_title": "动画包加载顺序", + "config.needsofnature.loaded_animations.load_order.summary": "顶部 = 最高优先级。外部 NoN 包覆盖内置内容。", + "config.needsofnature.loaded_animations.load_order.read_only": "连接远程服务器时只读。", + "config.needsofnature.loaded_animations.load_order.base_non": "内置 NeedsOfNature 内容(锁定)", + "config.needsofnature.loaded_animations_search": "按 ID/实体筛选", + "config.needsofnature.loaded_animations_summary": "已加载:%s | 显示:%s", + "config.needsofnature.loaded_animations.expand_hint": "缺失详情", + "config.needsofnature.loaded_animations.copied_id": "已复制动画 ID", + "config.needsofnature.loaded_animations.copied_missing": "已复制缺失详情", + "config.needsofnature.loaded_animations.status.ok": "正常", + "config.needsofnature.loaded_animations.status.partial": "部分", + "config.needsofnature.loaded_animations.status.missing": "缺失", + "config.needsofnature.ui_title": "自然需求界面", + "config.needsofnature.vanilla_override_title": "自然需求原版覆盖", + "config.needsofnature.initial_energy_max": "初始性欲上限(0-200):", + "config.needsofnature.energy_gain_rate": "性欲增长率(0.1-10 倍):", + "config.needsofnature.near_animation_energy_gain_mult": "动画附近性欲增长倍率(1-10 倍):", + "config.needsofnature.player_energy_aura_mult_low": "玩家性欲光环倍率(100-149):", + "config.needsofnature.player_energy_aura_mult_mid": "玩家性欲光环倍率(150-199):", + "config.needsofnature.player_energy_aura_mult_high": "玩家性欲光环倍率(200):", + "config.needsofnature.destroyed_skin_aura_mult_stage1": "破损皮肤光环倍率(第 1 阶段):", + "config.needsofnature.destroyed_skin_aura_mult_stage2": "破损皮肤光环倍率(第 2 阶段):", + "config.needsofnature.destroyed_skin_aura_mult_stage3": "破损皮肤光环倍率(第 3 阶段):", + "config.needsofnature.destroyed_skin_aura_mult_stage4": "破损皮肤光环倍率(第 4 阶段):", + "config.needsofnature.normal_damage_destroyed_skin_chance_percent": "普通伤害破损皮肤概率:", + "config.needsofnature.player_energy_aura_radius_min": "性欲 100 时的光环半径:", + "config.needsofnature.player_energy_aura_radius_max": "性欲 200 时的光环半径:", + "config.needsofnature.player_energy_aura_pulse_ticks": "性欲光环脉冲间隔(tick):", + "config.needsofnature.scan_budget": "扫描预算/每 tick(1-512):", + "config.needsofnature.debug_chat_mode": "调试聊天模式:", + "config.needsofnature.debug_spin_mode": "调试安全模式:", + "config.needsofnature.debug_spin_mode.value": "%s", + "config.needsofnature.convert_male_v_to_a": "男性专属 V 注入转为 A:", + "config.needsofnature.destroyed_skin_tint": "破损皮肤色调:", + "config.needsofnature.destroyed_skin_tint.pick": "选取破损皮肤色调...", + "config.needsofnature.destroyed_skin_tint.pick_title": "选取破损皮肤色调", + "config.needsofnature.destroyed_skin_tint.use": "使用颜色", + "config.needsofnature.destroyed_skin_tint.auto": "自动", + "config.needsofnature.destroyed_skin_tint.no_skin": "无法加载玩家皮肤。", + "config.needsofnature.loop_seconds": "普通阶段秒数(5-300):", + "config.needsofnature.peak_loop_seconds": "高潮阶段秒数(1-300):", + "config.needsofnature.gender_male": "全局雄性生成概率:", + "config.needsofnature.gender_female": "全局雌性生成概率:", + "config.needsofnature.gender_both": "全局双性别生成概率:", + "config.needsofnature.allow_player_gender_change_anytime": "允许随时更改性别:", + "config.needsofnature.require_player_gender_selection_on_join": "首次加入要求选择性别:", + "config.needsofnature.allowed_starting_genders": "允许的初始性别:", + "config.needsofnature.allowed_genders.all": "全部", + "config.needsofnature.allowed_genders.male_female": "男性或女性", + "config.needsofnature.allowed_genders.male": "仅男性", + "config.needsofnature.allowed_genders.female": "仅女性", + "config.needsofnature.allowed_genders.both": "仅男+女", + "config.needsofnature.allowed_genders.male_both": "男性或男+女", + "config.needsofnature.allowed_genders.female_both": "女性或男+女", + "config.needsofnature.toggle_label.enabled": "%s:开", + "config.needsofnature.toggle_label.disabled": "%s:关", + "config.needsofnature.player_gender": "玩家性别:", + "config.needsofnature.player_gender.male": "男性", + "config.needsofnature.player_gender.female": "女性", + "config.needsofnature.player_gender.both": "男+女", + "config.needsofnature.sync_female_gender_mod": "同步 Female Gender Mod:", + "config.needsofnature.open_female_gender_preview": "打开 Female Gender Mod 设置", + "config.needsofnature.female_gender.customization": "自定义", + "config.needsofnature.female_gender.physics": "胸部物理", + "config.needsofnature.female_gender.breast_size": "胸围:", + "config.needsofnature.female_gender.separation": "间隔:", + "config.needsofnature.female_gender.height": "高度:", + "config.needsofnature.female_gender.depth": "深度:", + "config.needsofnature.female_gender.rotation": "旋转:", + "config.needsofnature.female_gender.breast_physics": "胸部物理:", + "config.needsofnature.female_gender.dual_physics": "双胸物理:", + "config.needsofnature.female_gender.intensity": "强度:", + "config.needsofnature.female_gender.momentum": "动量:", + "config.needsofnature.female_gender.preview_unavailable": "预览不可用", + "config.needsofnature.armor_shed_effect": "护甲脱落效果:", + "config.needsofnature.action_sound_volume": "动作音效音量:", + "config.needsofnature.action_sound_volume.value": "%s%%", + "config.needsofnature.intiface_title": "自然需求 Buttplug.io", + "config.needsofnature.intiface_toy_title": "玩具设置", + "config.needsofnature.intiface_vibrator_title": "振动器设置", + "config.needsofnature.intiface_stroker_oscillator_title": "抽插器 / 振荡器设置", + "config.needsofnature.intiface.libs.install_title": "安装 Buttplug.io 库", + "config.needsofnature.intiface_enabled": "启用 Buttplug.io:", + "config.needsofnature.intiface_server_url": "服务器 URL:", + "config.needsofnature.intiface_auto_connect": "加入世界自动连接:", + "config.needsofnature.intiface_stop_on_disconnect": "断开时停止:", + "config.needsofnature.intiface_animation_baseline": "动画基线:", + "config.needsofnature.intiface_energized_level": "兴奋 %s 基础/脉冲:", + "config.needsofnature.intiface_reactiveimpact": "普通冲击:", + "config.needsofnature.intiface_peak_reactiveimpact": "高潮冲击:", + "config.needsofnature.intiface_cooldown": "脉冲冷却(毫秒):", + "config.needsofnature.intiface_connect_scan": "连接 / 扫描:", + "config.needsofnature.intiface_test_pulse": "测试脉冲:", + "config.needsofnature.intiface_toy_settings": "玩具设置", + "config.needsofnature.intiface_vibrator_settings": "振动器设置", + "config.needsofnature.intiface_stroker_oscillator_settings": "抽插器 / 振荡器设置", + "config.needsofnature.intiface_oscillator_regular_speed": "普通动画速度:", + "config.needsofnature.intiface_oscillator_peak_speed": "高潮阶段速度:", + "config.needsofnature.intiface_stroker_max_distance": "最大距离:", + "config.needsofnature.intiface_stroker_min_distance": "最小距离:", + "config.needsofnature.intiface_stroker_regular_duration": "普通移动时长:", + "config.needsofnature.intiface_stroker_peak_duration": "高潮移动时长:", + "config.needsofnature.intiface_connect": "连接", + "config.needsofnature.intiface_scan": "扫描", + "config.needsofnature.intiface_test": "测试", + "config.needsofnature.intiface_disconnect": "断开", + "config.needsofnature.intiface.libs.install": "安装库", + "config.needsofnature.intiface.libs.download": "下载", + "config.needsofnature.intiface.libs.redownload": "重新下载库", + "config.needsofnature.intiface.libs.restart_required": "需要重启", + "config.needsofnature.intiface.libs.install_line_1": "Buttplug.io 支持需要 NoN 未附带的可选 Java 库。", + "config.needsofnature.intiface.libs.install_line_2": "下载大小:%s(来自 Maven Central)。需要重启 Minecraft。", + "config.needsofnature.intiface.libs.install_line_3": "没有服务器设置控制此项;只影响你的本地客户端。", + "config.needsofnature.intiface.status.disabled": "已禁用", + "config.needsofnature.intiface.status.unavailable": "不可用", + "config.needsofnature.intiface.status.disconnected": "未连接", + "config.needsofnature.intiface.status.connecting": "连接中", + "config.needsofnature.intiface.status.connected": "已连接(%s 台设备)", + "config.needsofnature.intiface.status.scanning": "扫描中(%s 台设备)", + "config.needsofnature.intiface.status.error": "错误(%s 台设备)", + "config.needsofnature.intiface.status.device_added": "设备已添加:%s", + "config.needsofnature.intiface.status.device_removed": "设备已移除", + "config.needsofnature.intiface.status.scan_finished": "扫描完成", + "config.needsofnature.intiface.status.no_devices": "未连接设备", + "config.needsofnature.intiface.status.no_vibrate_devices": "没有支持振动的设备", + "config.needsofnature.intiface.status.stopped": "已停止所有设备", + "config.needsofnature.intiface.libs.status.available": "库已就绪", + "config.needsofnature.intiface.libs.status.missing": "库缺失(%s 下载)", + "config.needsofnature.intiface.libs.status.outdated": "库已过时;需要重新下载", + "config.needsofnature.intiface.libs.status.installing": "安装中:%s", + "config.needsofnature.intiface.libs.status.building": "构建库包", + "config.needsofnature.intiface.libs.status.restart_required": "已安装,重启 Minecraft 以激活", + "config.needsofnature.intiface.libs.status.failed": "安装失败:%s", + "config.needsofnature.male_male_chance": "男+男概率(0-100):", + "config.needsofnature.female_female_chance": "女+女概率(0-100):", + "config.needsofnature.entity_entity_chance": "生物+生物概率(0-100):", + "config.needsofnature.multi_actor_join_chance": "加入概率(3+ 演员)(0-100):", + "config.needsofnature.section.world": "世界", + "config.needsofnature.section.energy": "性欲", + "config.needsofnature.section.animations": "动画", + "config.needsofnature.section.difficulty": "难度", + "config.needsofnature.section.interface": "界面", + "config.needsofnature.section.player_identity": "玩家身份", + "config.needsofnature.section.local_visuals": "本地外观", + "config.needsofnature.section.intiface_connection": "Buttplug.io 连接", + "config.needsofnature.section.intiface_libraries": "Buttplug.io 库", + "config.needsofnature.section.intiface_baseline": "动画基线", + "config.needsofnature.section.intiface_energized": "兴奋心跳", + "config.needsofnature.section.intiface_reactiveimpact": "反应冲击", + "config.needsofnature.section.intiface_controls": "连接控制", + "config.needsofnature.section.intiface_toy_settings": "玩具设置", + "config.needsofnature.section.intiface_oscillator": "振荡器", + "config.needsofnature.section.intiface_stroker": "抽插器", + "config.needsofnature.section.mob_energy": "生物性欲", + "config.needsofnature.section.gender_spawn_chances": "性别生成概率", + "config.needsofnature.section.player_gender_rules": "玩家性别规则", + "config.needsofnature.section.energy_gain": "性欲增长", + "config.needsofnature.section.player_energy_aura": "玩家性欲光环", + "config.needsofnature.section.destroyed_skin": "破损皮肤", + "config.needsofnature.section.aura_range_timing": "光环范围与时机", + "config.needsofnature.section.animation_pacing": "动画节奏", + "config.needsofnature.section.match_chances": "匹配概率", + "config.needsofnature.section.attack_escape": "攻击挣脱", + "config.needsofnature.section.attack_rules": "攻击规则", + "config.needsofnature.section.last_defeated": "最后落败", + "config.needsofnature.section.core_systems": "核心系统", + "config.needsofnature.section.vanilla_integration": "原版集成", + "config.needsofnature.section.breeding": "繁殖", + "config.needsofnature.section.milking": "挤奶", + "config.needsofnature.section.pregnancy_timing": "怀孕时机", + "config.needsofnature.section.pregnancy_behavior": "怀孕行为", + "config.needsofnature.section.liquid_tank": "体液储槽", + "config.needsofnature.section.filled_penalties": "灌满惩罚", + "config.needsofnature.section.runtime_budget": "运行时预算", + "config.needsofnature.section.debug_feedback": "调试反馈", + "config.needsofnature.section.animation_data": "动画数据", + "config.needsofnature.toggle.pregnancy": "怀孕开关:", + "config.needsofnature.toggle.liquid_tank": "体液储槽开关:", + "config.needsofnature.toggle.mess_system": "污渍系统开关:", + "config.needsofnature.toggle.destroyed_skin_system": "破损皮肤系统开关:", + "config.needsofnature.toggle.vanilla_overrides": "原版覆盖开关:", + "config.needsofnature.use_animation_breeding": "使用动画繁殖覆盖:", + "config.needsofnature.require_male_female_breeding": "繁殖要求雄+雌:", + "config.needsofnature.female_cow_only_milking": "仅雌牛挤奶:", + "config.needsofnature.attack_hits": "攻击挣脱次数(1-50):", + "config.needsofnature.attack_decay": "挣脱衰减/秒(0-5):", + "config.needsofnature.attack_escape_invulnerability_ticks": "挣脱后无敌 tick(0-1200):", + "config.needsofnature.post_escape_gather_max_mobs": "挣脱后追赶者(0-64):", + "config.needsofnature.attack_outcome_failsafe_threshold": "攻击失败保险次数(1-50):", + "config.needsofnature.attack_creative_players": "允许攻击创造模式玩家:", + "config.needsofnature.last_defeated_enabled": "最后落败救援:", + "config.needsofnature.last_defeated_energy_threshold": "最后落败性欲(0-200):", + "config.needsofnature.last_defeated_search_radius": "最后落败半径(1-32):", + "config.needsofnature.last_defeated_cooldown_seconds": "最后落败冷却秒数:", + "config.needsofnature.pregnancy_chance_percent": "受孕概率(0-100):", + "config.needsofnature.pregnancy_duration_minutes": "妊娠时长(分钟):", + "config.needsofnature.pregnancy_egg_hatch_minutes": "怀孕蛋孵化时间(分钟):", + "config.needsofnature.pregnancy_auto_tame_mobs": "自动驯服可驯服后代:", + "config.needsofnature.pregnancy_friendly_mobs_ignore_players": "敌对后代忽略玩家:", + "config.needsofnature.offspring_count_button": "实体怀孕档案...", + "config.needsofnature.liquid_tank_capacity": "体液储槽容量(ml):", + "config.needsofnature.liquid_decay": "体液流失/秒(0-20):", + "config.needsofnature.peak_xp_per_ml": "高潮每 ml 经验:", + "config.needsofnature.liquid_puddle_despawn_seconds": "体液水洼消失秒数(1-300):", + "config.needsofnature.filled_stage_one_speed": "灌满速度倍率(第 1 阶段):", + "config.needsofnature.filled_stage_two_speed": "灌满速度倍率(第 2 阶段):", + "config.needsofnature.filled_stage_three_speed": "灌满速度倍率(第 3 阶段):", + "config.needsofnature.filled_stage_one_jump": "灌满跳跃倍率(第 1 阶段):", + "config.needsofnature.filled_stage_two_jump": "灌满跳跃倍率(第 2 阶段):", + "config.needsofnature.filled_stage_three_jump": "灌满跳跃倍率(第 3 阶段):", + "config.needsofnature.gender_spawn_button": "性别生成...", + "config.needsofnature.gender_spawn_title": "各实体的性别生成概率", + "config.needsofnature.gender_spawn_add": "添加入口", + "config.needsofnature.gender_spawn_reset": "重置", + "config.needsofnature.gender_spawn_reset_title": "重置性别生成概率?", + "config.needsofnature.gender_spawn_reset_body": "这将用 NoNpack 默认值替换实体行。", + "config.needsofnature.gender_spawn_column.entity": "实体", + "config.needsofnature.gender_spawn_column.male": "雄性", + "config.needsofnature.gender_spawn_column.female": "雌性", + "config.needsofnature.gender_spawn_column.both": "双性", + "config.needsofnature.gender_spawn_invalid_total": "总计必须为 100", + "config.needsofnature.liquid_gain_button": "体液增加...", + "config.needsofnature.liquid_gain_title": "各实体的体液增加", + "config.needsofnature.liquid_gain_add": "添加入口", + "config.needsofnature.liquid_gain_reset": "重置", + "config.needsofnature.liquid_gain_reset_title": "重置体液增加?", + "config.needsofnature.liquid_gain_reset_body": "这将用默认值替换所有入口。", + "config.needsofnature.liquid_gain_column.entity": "实体", + "config.needsofnature.liquid_gain_column.ml": "ml", + "config.needsofnature.liquid_gain_column.color": "颜色", + "config.needsofnature.offspring_count_title": "各实体的怀孕档案", + "config.needsofnature.offspring_count_add": "添加入口", + "config.needsofnature.offspring_count_reset": "重置", + "config.needsofnature.offspring_count_reset_title": "重置怀孕档案?", + "config.needsofnature.offspring_count_reset_body": "这将用 NoNpack 默认值替换实体行。", + "config.needsofnature.offspring_count_column.entity": "实体", + "config.needsofnature.entity_profile_column.chance": "概率", + "config.needsofnature.offspring_count_column.min": "最小", + "config.needsofnature.offspring_count_column.max": "最大", + "config.needsofnature.entity_profile_column.birth_entity": "出生实体", + "config.needsofnature.entity_profile_column.birth_mode": "模式", + "config.needsofnature.apply": "应用", + "config.needsofnature.done": "完成", + "config.needsofnature.done_unsaved": "不保存离开", + "config.needsofnature.reset_icon": "<", + "config.needsofnature.remove_icon": "x", + "config.needsofnature.debug_chat_mode.all": "全部", + "config.needsofnature.debug_chat_mode.setup_warnings_errors": "警告、设置、错误", + "config.needsofnature.debug_chat_mode.setup_errors": "设置 + 错误", + "config.needsofnature.debug_chat_mode.errors_only": "仅错误", + "config.needsofnature.ui_energy_x": "性欲条 X:", + "config.needsofnature.ui_energy_y": "性欲条 Y:", + "config.needsofnature.ui_attack_x": "攻击条 X:", + "config.needsofnature.ui_attack_y": "攻击条 Y:", + "config.needsofnature.ui_prompt_x": "攻击提示 X:", + "config.needsofnature.ui_prompt_y": "攻击提示 Y:", + "config.needsofnature.ui_liquid_x": "体液储槽 X:", + "config.needsofnature.ui_liquid_y": "体液储槽 Y:", + "config.needsofnature.ui_energy": "循环进度", + "config.needsofnature.ui_energy_heart": "性欲心", + "config.needsofnature.ui_attack": "攻击条", + "config.needsofnature.ui_prompt": "攻击提示", + "config.needsofnature.ui_liquid": "体液储槽", + "config.needsofnature.ui_drag_help": "拖拽 HUD 元素移动。方向键微调选中元素。", + "config.needsofnature.ui_selected": "已选中:%s", + "config.needsofnature.ui_selected_hidden": "已选中:%s(隐藏)", + "config.needsofnature.ui_hidden_label": "%s(隐藏)", + "config.needsofnature.ui_hide_selected": "隐藏选中", + "config.needsofnature.ui_show_selected": "显示选中", + "config.needsofnature.ui_reset_selected": "重置选中", + "config.needsofnature.ui_reset_all": "全部重置", + "config.needsofnature.tooltip.ui_toolbar_visibility": "显示或隐藏界面编辑器按钮。", + "config.needsofnature.tooltip.ui_toggle_selected_visibility": "显示或隐藏当前选中的 HUD 元素。", + "config.needsofnature.tooltip.category.toggles": "主要 NON 系统的主开关。", + "config.needsofnature.tooltip.category.player_preferences": "客户端持有的玩家偏好,如玩家性别和破损皮肤色调。", + "config.needsofnature.tooltip.category.settings": "玩法、覆盖、体液、怀孕、调试和高级设置。", + "config.needsofnature.tooltip.category.gameplay": "性欲、匹配和挣脱行为设置。", + "config.needsofnature.tooltip.category.vanilla_overrides": "覆盖原版繁殖和挤奶行为。", + "config.needsofnature.tooltip.category.liquid": "体液增加、流失、装瓶和灌满效果设置。", + "config.needsofnature.tooltip.category.pregnancy": "受孕概率、时长和蛋孵化设置。", + "config.needsofnature.tooltip.category.interface": "移动 NON HUD 元素以获得更好的对齐。", + "config.needsofnature.tooltip.category.intiface": "可选的本机 Buttplug.io 玩具控制集成,用于你的动画事件。", + "config.needsofnature.tooltip.category.debug_advanced": "调试输出和面向性能的高级选项。", + "config.needsofnature.tooltip.category.disabled_in_toggles": "已在开关中禁用。", + "config.needsofnature.tooltip.toggle.pregnancy": "全局启用或禁用怀孕机制。", + "config.needsofnature.tooltip.toggle.liquid_tank": "全局启用或禁用玩家体液储槽机制。", + "config.needsofnature.tooltip.toggle.mess_system": "全局启用或禁用污渍增加、清洁和可见污渍覆盖。", + "config.needsofnature.tooltip.toggle.destroyed_skin_system": "全局启用或禁用破损皮肤伤害、修复、手动剪毛和可见破损皮肤覆盖。", + "config.needsofnature.tooltip.toggle.vanilla_overrides": "全局启用或禁用所有 NON 原版覆盖机制。", + "config.needsofnature.tooltip.initial_energy_max": "生物首次初始化时分配的最大随机性欲。", + "config.needsofnature.tooltip.energy_gain_rate": "自然生物性欲随时间增长的全局倍率。", + "config.needsofnature.tooltip.near_animation_energy_gain_mult": "生物看到附近活跃动画时获得的额外性欲增长倍率。", + "config.needsofnature.tooltip.player_energy_aura_mult_low": "当玩家性欲为 100-149 时,附近符合条件的生物获得性欲,如同其自然增长率乘以该数值。", + "config.needsofnature.tooltip.player_energy_aura_mult_mid": "当玩家性欲为 150-199 时,附近符合条件的生物获得性欲,如同其自然增长率乘以该数值。", + "config.needsofnature.tooltip.player_energy_aura_mult_high": "当玩家性欲为 200 时,附近符合条件的生物获得性欲,如同其自然增长率乘以该数值。", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage1": "玩家处于破损皮肤第 1 阶段时施加到性欲光环上的额外倍率。", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage2": "玩家处于破损皮肤第 2 阶段时施加到性欲光环上的额外倍率。", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage3": "玩家处于破损皮肤第 3 阶段时施加到性欲光环上的额外倍率。", + "config.needsofnature.tooltip.destroyed_skin_aura_mult_stage4": "玩家处于破损皮肤第 4 阶段时施加到性欲光环上的额外倍率。", + "config.needsofnature.tooltip.normal_damage_destroyed_skin_chance_percent": "非动画玩家伤害增加一次皮肤破损的百分比概率。", + "config.needsofnature.tooltip.player_energy_aura_radius_min": "性欲恰好为 100 时玩家性欲光环使用的方块半径。", + "config.needsofnature.tooltip.player_energy_aura_radius_max": "性欲为 200 时玩家性欲光环使用的方块半径。", + "config.needsofnature.tooltip.player_energy_aura_pulse_ticks": "玩家性欲光环施加额外生物性欲的频率,以 tick 计。", + "config.needsofnature.tooltip.loop_seconds": "循环非高潮阶段自动推进前的默认秒数。阶段时长倍率按阶段缩放此值。", + "config.needsofnature.tooltip.peak_loop_seconds": "循环高潮阶段自动推进前的默认秒数。阶段时长倍率按阶段缩放此值。", + "config.needsofnature.tooltip.gender_male": "新初始化生物只获得雄性性别标签的默认百分比概率。全局男/女/双值必须合计为 100。", + "config.needsofnature.tooltip.gender_female": "新初始化生物只获得雌性性别标签的默认百分比概率。全局男/女/双值必须合计为 100。", + "config.needsofnature.tooltip.gender_both": "新初始化生物同时获得两种性别标签的默认百分比概率。全局男/女/双值必须合计为 100。", + "config.needsofnature.tooltip.allow_player_gender_change_anytime": "禁用后,玩家无法在性别选定或分配后自由更改其 NON 性别。", + "config.needsofnature.tooltip.require_player_gender_selection_on_join": "启用后,没有保存 NON 性别的玩家必须在加入时选择一个。", + "config.needsofnature.tooltip.allowed_starting_genders": "控制首次加入性别选择界面中可用的性别。", + "config.needsofnature.tooltip.player_gender": "你的性别,用于动画资格和玩家 .m/.f 模型选择。", + "config.needsofnature.tooltip.player_gender.locked": "此服务器禁用了性别更改。", + "config.needsofnature.tooltip.sync_female_gender_mod": "保持 Female Gender Mod 的本地玩家性别与你的 NON 玩家性别一致。男+女同步为其他。", + "config.needsofnature.tooltip.destroyed_skin_female_gender_overrides": "根据你的破损皮肤阶段写入 Female Gender Mod 身体设置。关闭后停止后续写入并保留最近写入的值。", + "config.needsofnature.tooltip.destroyed_skin_female_gender_profiles": "配置 NON 为破损阶段 0-1 和 2-4 写入的 Female Gender Mod 身体设置。", + "config.needsofnature.tooltip.open_female_gender_preview": "打开 Female Gender Mod 自己的设置界面。", + "config.needsofnature.tooltip.intiface_enabled": "通过 Buttplug.io 启用本地玩具控制。默认禁用。", + "config.needsofnature.tooltip.intiface_server_url": "本地 Buttplug.io 服务器的 WebSocket URL,通常为 ws://127.0.0.1:12345。", + "config.needsofnature.tooltip.intiface_auto_connect": "加入世界时自动连接 Buttplug.io。", + "config.needsofnature.tooltip.intiface_stop_on_disconnect": "离开世界时向已连接设备发送停止命令。", + "config.needsofnature.tooltip.intiface_animation_baseline": "处于动画中时的低振动。反应冲击会暂时超过它。设为 0% 以禁用。", + "config.needsofnature.tooltip.intiface_energized": "兴奋激活时的基础振动,加上与心跳同步的高潮峰值强度。左滑块为基础,右滑块为脉冲峰值。", + "config.needsofnature.tooltip.intiface_reactiveimpact": "普通反应冲击提示的强度滑块和毫秒时长。", + "config.needsofnature.tooltip.intiface_peak_reactiveimpact": "高潮反应冲击提示的强度滑块和毫秒时长。", + "config.needsofnature.tooltip.intiface_cooldown": "脉冲之间的最小毫秒数。设为 0 以允许每个匹配提示。", + "config.needsofnature.tooltip.intiface_status": "当前本地 Buttplug.io 连接状态和设备数量。", + "config.needsofnature.tooltip.intiface_libraries_status": "可选的 Buttplug.io 支持库是否已安装并加载。", + "config.needsofnature.tooltip.intiface_libraries_install": "从 Maven Central 下载经过验证的库,并在 mods 文件夹中创建生成的库 jar。需要重启。", + "config.needsofnature.tooltip.intiface_connect_scan": "连接 Buttplug.io 并扫描设备。", + "config.needsofnature.tooltip.intiface_test_pulse": "使用配置的普通冲击配置发送手动测试脉冲。", + "config.needsofnature.tooltip.intiface_toy_settings": "打开振动器、抽插器和振荡器的玩具响应设置。", + "config.needsofnature.tooltip.intiface_vibrator_settings": "配置振动器基线和反应冲击脉冲。", + "config.needsofnature.tooltip.intiface_stroker_oscillator_settings": "配置抽插器和振荡器响应。", + "config.needsofnature.tooltip.intiface_oscillator_regular_speed": "处于普通动画阶段时的振荡器速度。设为 0% 以禁用。", + "config.needsofnature.tooltip.intiface_oscillator_peak_speed": "处于高潮动画阶段时的振荡器速度。设为 0% 以禁用。", + "config.needsofnature.tooltip.intiface_stroker_max_distance": "反应冲击提示时立即使用的抽插器位置。", + "config.needsofnature.tooltip.intiface_stroker_min_distance": "下一个反应冲击提示前抽插器瞄准的位置。", + "config.needsofnature.tooltip.intiface_stroker_regular_duration": "普通反应冲击提示时抽插器 API 移动到最大或最小位置使用的毫秒数。设为 0ms 以禁用。", + "config.needsofnature.tooltip.intiface_stroker_peak_duration": "高潮反应冲击提示时抽插器 API 移动到最大或最小位置使用的毫秒数。设为 0ms 以禁用。", + "config.needsofnature.tooltip.female_gender.range": "Female Gender Mod 值范围:%s。", + "config.needsofnature.tooltip.female_gender.breast_physics": "为此破损阶段档案启用或禁用 Female Gender Mod 胸部物理。", + "config.needsofnature.tooltip.female_gender.dual_physics": "开:使用独立左右物理。关:写入 Female Gender Mod 的单胸设置。", + "config.needsofnature.tooltip.armor_shed_effect": "客户端视觉效果,进入新动画时显示装备的护甲飞落。", + "config.needsofnature.tooltip.action_sound_volume": "缩放由动画音效提示解析出的 NoN 动作音效。Minecraft 的常规音效分类滑块仍然生效。", + "config.needsofnature.tooltip.male_male_chance": "同性雄性配对继续进行的掷骰概率。", + "config.needsofnature.tooltip.female_female_chance": "同性雌性配对继续进行的掷骰概率。", + "config.needsofnature.tooltip.entity_entity_chance": "任何生物+生物动画尝试在匹配前进行的掷骰概率。", + "config.needsofnature.tooltip.multi_actor_join_chance": "总演员数将达到 3+ 时加入的掷骰概率。", + "config.needsofnature.tooltip.use_animation_breeding": "启用后,处于恋爱状态的动物会先尝试通过 NON 动画繁殖,然后再生成幼崽。", + "config.needsofnature.tooltip.require_male_female_breeding": "启用后,动画繁殖只允许一雄一雌双亲。", + "config.needsofnature.tooltip.require_male_female_breeding.disabled_animation_breeding": "已禁用,因为动画繁殖已关闭。", + "config.needsofnature.tooltip.female_cow_only_milking": "启用后,只有雌性(或双性别)奶牛可以用桶挤奶。", + "config.needsofnature.tooltip.attack_hits": "挣脱攻击动画所需的基础 A/D 命中次数。", + "config.needsofnature.tooltip.attack_decay": "未按键时挣脱进度每秒衰减的速度。", + "config.needsofnature.tooltip.attack_escape_invulnerability_ticks": "挣脱攻击动画后玩家免伤的时间。20 tick = 1 秒。", + "config.needsofnature.tooltip.post_escape_gather_max_mobs": "成功挣脱攻击后立即开始追赶的附近 70+ 性欲生物的最大数量。设为 0 以禁用。", + "config.needsofnature.tooltip.attack_outcome_failsafe_threshold": "在 10 分钟内生物可以挣脱或高潮多少次攻击动画,超过后其性欲在保险冷却期间锁定为 0。", + "config.needsofnature.tooltip.attack_creative_players": "启用后,生物可以攻击并对创造模式玩家发起攻击动画。", + "config.needsofnature.tooltip.last_defeated_enabled": "启用后,致命生物伤害可以让玩家保留半颗心并开始落败动画,而不是死亡。", + "config.needsofnature.tooltip.last_defeated_energy_threshold": "触发最后落败救援所需的最低生物性欲。", + "config.needsofnature.tooltip.last_defeated_search_radius": "造成伤害的生物无法触发有效救援时使用的附近生物搜索半径。", + "config.needsofnature.tooltip.last_defeated_cooldown_seconds": "一次救援后,同一玩家再次被救援前的冷却时间。", + "config.needsofnature.tooltip.pregnancy_chance_percent": "达到高潮的玩家动画开始怀孕的概率。", + "config.needsofnature.tooltip.pregnancy_duration_minutes": "怀孕持续多少现实分钟后才可孵化。", + "config.needsofnature.tooltip.pregnancy_egg_hatch_minutes": "怀孕蛋实体的默认孵化时间(分钟)。", + "config.needsofnature.tooltip.pregnancy_auto_tame_mobs": "启用后,可驯服的怀孕后代会自动被生成它们的玩家驯服或绑定。", + "config.needsofnature.tooltip.pregnancy_friendly_mobs_ignore_players": "启用后,敌对的怀孕后代被阻止以任何玩家为目标。", + "config.needsofnature.tooltip.offspring_count_button": "打开按实体的怀孕档案编辑器。", + "config.needsofnature.tooltip.liquid_tank_capacity": "玩家储槽可容纳的体液最大量。", + "config.needsofnature.tooltip.liquid_decay": "每秒从玩家体液储槽中流失的毫升数。", + "config.needsofnature.tooltip.peak_xp_per_ml": "达到高潮动画后每个符合 XP 条件的 ml 生成的体验。默认为 0.0667,等于每 15 ml 1 XP。", + "config.needsofnature.tooltip.liquid_puddle_despawn_seconds": "生成的体液水洼粒子的寿命(秒)。", + "config.needsofnature.tooltip.filled_stage_one_speed": "处于灌满第 1 阶段时的移动速度倍率。", + "config.needsofnature.tooltip.filled_stage_two_speed": "处于灌满第 2 阶段时的移动速度倍率。", + "config.needsofnature.tooltip.filled_stage_three_speed": "处于灌满第 3 阶段时的移动速度倍率。", + "config.needsofnature.tooltip.filled_stage_one_jump": "处于灌满第 1 阶段时的跳跃倍率。", + "config.needsofnature.tooltip.filled_stage_two_jump": "处于灌满第 2 阶段时的跳跃倍率。", + "config.needsofnature.tooltip.filled_stage_three_jump": "处于灌满第 3 阶段时的跳跃倍率。", + "config.needsofnature.tooltip.liquid_gain_button": "打开按实体的体液增加和颜色覆盖编辑器。", + "config.needsofnature.tooltip.gender_spawn_button": "打开按实体的性别生成概率编辑器。现有生物保持其当前性别。", + "config.needsofnature.tooltip.scan_budget": "每个世界 tick 的最大生物扫描操作数,超过后跳过额外扫描。", + "config.needsofnature.tooltip.debug_chat_mode": "控制显示哪些 NON 调试聊天分类。", + "config.needsofnature.tooltip.debug_spin_mode": "客户端视觉覆盖:用简单的旋转调试片段替换动画。", + "config.needsofnature.tooltip.convert_male_v_to_a": "启用后,男性专属玩家可以使用 V 注入动画,并将这些高潮按 A 处理储槽和污渍行为。", + "config.needsofnature.tooltip.destroyed_skin_tint": "用于生成的破损皮肤回退的十六进制 RGB 色调。留空以从你的皮肤自动检测。", + "config.needsofnature.tooltip.destroyed_skin_tint.pick": "打开你的完整皮肤纹理并点击像素以使用其颜色。", + "config.needsofnature.tooltip.destroyed_skin_tint.custom_skin": "已禁用,因为存在 config/needsofnature/destroyed_skin.png。自定义破损皮肤忽略生成的皮肤色调。", + "config.needsofnature.tooltip.destroyed_skin_tint.disabled_system": "已禁用,因为破损皮肤系统在开关中已关闭。", + "config.needsofnature.tooltip.destroyed_skin_tint.default_skin": "已禁用,因为选定的默认皮肤有自己的破损皮肤纹理。默认破损皮肤忽略生成的皮肤色调。", + "config.needsofnature.tooltip.destroyed_skin_system.disabled_setting": "已禁用,因为破损皮肤系统在开关中已关闭。", + "config.needsofnature.tooltip.destroyed_skin_tint.auto": "清除手动色调,让 NON 再次从你的皮肤自动检测。", + "config.needsofnature.tooltip.loaded_animations": "查看所有已加载的动画定义和运行时 GeckoLib 资源状态。", + "config.needsofnature.tooltip.loaded_animations.load_order": "重新排序外部 NoN 包。在世界中更改会自动重新加载。", + "config.needsofnature.tooltip.loaded_animations.load_order.reset": "将外部 NoN 包顺序重置为默认字母顺序。", + "config.needsofnature.tooltip.liquid_gain_add": "添加新的实体体液行。", + "config.needsofnature.tooltip.liquid_gain_reset": "将所有体液增加/颜色行重置为默认值。", + "config.needsofnature.tooltip.gender_spawn_add": "添加新的实体性别生成行。", + "config.needsofnature.tooltip.gender_spawn_reset": "将实体性别生成行重置为 NoNpack 默认值。", + "config.needsofnature.tooltip.offspring_count_add": "添加新的实体怀孕档案行。", + "config.needsofnature.tooltip.offspring_count_reset": "将怀孕档案行重置为 NoNpack 默认值。", + "config.needsofnature.tooltip.gender_spawn_entry_entity": "实体 ID,例如:minecraft:wolf", + "config.needsofnature.tooltip.gender_spawn_entry_male": "雄性专属生成百分比。除非男+女+双合计恰好为 100,否则忽略该行。", + "config.needsofnature.tooltip.gender_spawn_entry_female": "雌性专属生成百分比。除非男+女+双合计恰好为 100,否则忽略该行。", + "config.needsofnature.tooltip.gender_spawn_entry_both": "双性别生成百分比。除非男+女+双合计恰好为 100,否则忽略该行。", + "config.needsofnature.tooltip.liquid_gain_entry_entity": "实体 ID,例如:minecraft:wolf", + "config.needsofnature.tooltip.liquid_gain_entry_ml": "此实体用作注入方时给予的体液毫升数。", + "config.needsofnature.tooltip.liquid_gain_entry_color": "可选的实体覆盖颜色,十六进制(#RRGGBB)。", + "config.needsofnature.tooltip.offspring_count_entry_entity": "实体 ID,例如:minecraft:wolf", + "config.needsofnature.tooltip.entity_profile_entry_chance": "此实体的受孕概率覆盖。留空以使用显示的默认/全局值。", + "config.needsofnature.tooltip.offspring_count_entry_min": "最小后代数量。数值限制在 1-16。", + "config.needsofnature.tooltip.offspring_count_entry_max": "最大后代数量。低于最小的数值会提升到最小。", + "config.needsofnature.tooltip.entity_profile_entry_birth_entity": "从此怀孕生成的实体 ID。留空使用来源实体。", + "config.needsofnature.tooltip.entity_profile_entry_birth_mode": "出生模式:蛋或直接。", + "config.needsofnature.tooltip.apply": "应用更改而不离开此界面。", + "config.needsofnature.tooltip.done": "关闭此界面并返回。", + "config.needsofnature.tooltip.done_save": "保存更改并返回。", + "config.needsofnature.tooltip.done_unsaved": "不保存未保存的更改就离开。", + "config.needsofnature.tooltip.reset": "将此值重置为默认值。", + "config.needsofnature.tooltip.remove": "移除此行。", + "screen.needsofnature.warning.proceed": "我了解,继续", + "screen.needsofnature.warning.proceed_countdown": "等待 %s 秒...", + "screen.needsofnature.warning.close_game": "关闭游戏", + "screen.needsofnature.warning.dont_show_again": "不再显示此警告", + "screen.needsofnature.warning.pack": "来自 NoNpack:%s", + "effect.needsofnature.energy": "性欲", + "effect.needsofnature.energy_relief": "性欲缓解", + "effect.needsofnature.energized": "兴奋", + "effect.needsofnature.fertility_increaser": "受孕提升", + "effect.needsofnature.filled": "被灌满", + "effect.needsofnature.pregnant": "怀孕", + "item.minecraft.potion.effect.needsofnature.energy": "性欲药水", + "item.minecraft.splash_potion.effect.needsofnature.energy": "喷溅型性欲药水", + "item.minecraft.lingering_potion.effect.needsofnature.energy": "滞留型性欲药水", + "item.minecraft.tipped_arrow.effect.needsofnature.energy": "性欲之箭", + "item.minecraft.potion.effect.needsofnature.energy_ii": "性欲药水 II", + "item.minecraft.splash_potion.effect.needsofnature.energy_ii": "喷溅型性欲药水 II", + "item.minecraft.lingering_potion.effect.needsofnature.energy_ii": "滞留型性欲药水 II", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_ii": "性欲之箭 II", + "item.minecraft.potion.effect.needsofnature.energy_long": "性欲药水", + "item.minecraft.splash_potion.effect.needsofnature.energy_long": "喷溅型性欲药水", + "item.minecraft.lingering_potion.effect.needsofnature.energy_long": "滞留型性欲药水", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_long": "性欲之箭", + "item.minecraft.potion.effect.needsofnature.energy_relief": "性欲缓解药水", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief": "喷溅型性欲缓解药水", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief": "滞留型性欲缓解药水", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief": "性欲缓解之箭", + "item.minecraft.potion.effect.needsofnature.energy_relief_ii": "性欲缓解药水 II", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief_ii": "喷溅型性欲缓解药水 II", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief_ii": "滞留型性欲缓解药水 II", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief_ii": "性欲缓解之箭 II", + "item.minecraft.potion.effect.needsofnature.energy_relief_long": "性欲缓解药水", + "item.minecraft.splash_potion.effect.needsofnature.energy_relief_long": "喷溅型性欲缓解药水", + "item.minecraft.lingering_potion.effect.needsofnature.energy_relief_long": "滞留型性欲缓解药水", + "item.minecraft.tipped_arrow.effect.needsofnature.energy_relief_long": "性欲缓解之箭", + "item.minecraft.potion.effect.needsofnature.fertile_nectar": "易孕花蜜", + "item.minecraft.splash_potion.effect.needsofnature.fertile_nectar": "喷溅型易孕花蜜", + "item.minecraft.lingering_potion.effect.needsofnature.fertile_nectar": "滞留型易孕花蜜", + "item.minecraft.tipped_arrow.effect.needsofnature.fertile_nectar": "易孕花蜜之箭", + "item.minecraft.potion.effect.needsofnature.fertility_increaser": "受孕提升药水", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser": "喷溅型受孕提升药水", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser": "滞留型受孕提升药水", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser": "受孕提升之箭", + "item.minecraft.potion.effect.needsofnature.fertility_increaser_ii": "受孕提升药水 II", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser_ii": "喷溅型受孕提升药水 II", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser_ii": "滞留型受孕提升药水 II", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser_ii": "受孕提升之箭 II", + "item.minecraft.potion.effect.needsofnature.fertility_increaser_long": "受孕提升药水", + "item.minecraft.splash_potion.effect.needsofnature.fertility_increaser_long": "喷溅型受孕提升药水", + "item.minecraft.lingering_potion.effect.needsofnature.fertility_increaser_long": "滞留型受孕提升药水", + "item.minecraft.tipped_arrow.effect.needsofnature.fertility_increaser_long": "受孕提升之箭", + "screen.needsofnature.gender_selection.title": "选择你的 NON 性别", + "screen.needsofnature.gender_selection.permanent": "此服务器之后不允许自由更改性别。", + "screen.needsofnature.gender_selection.changeable": "此选择之后可以在玩家偏好中更改。", + "screen.needsofnature.gender_selection.subtitle": "选择 NON 应如何对待你的玩家。", + "screen.needsofnature.gender_selection.option.male": "♂ 男性", + "screen.needsofnature.gender_selection.option.female": "♀ 女性", + "screen.needsofnature.gender_selection.option.both": "♂♀ 男+女", + "screen.needsofnature.gender_selection.current": "%s(当前)", + "debug.needsofnature.prefix": "[NoN] %s", + "debug.needsofnature.join_stage_disallows": "当前动画阶段此时不接受加入。", + "debug.needsofnature.mob_wants_join": "生物 %s 想加入你的动画(性欲 %s,动画 %s)。", + "debug.needsofnature.join_request_accepted": "生物 %s 的加入请求已被接受(动画 %s)。", + "debug.needsofnature.join_request_denied": "生物 %s 的加入请求已被拒绝。", + "debug.needsofnature.starting_animation_with_mob_200": "开始与生物 %s 的动画(200 性欲)。", + "debug.needsofnature.gathering_with_mob": "正在与生物 %s 聚集。", + "debug.needsofnature.mob_pair_attempt": "%s 试图与 %s 聚集(性欲 %s/%s,动画 %s)。", + "debug.needsofnature.mob_pair_gathering": "已排队聚集:%s 与 %s(动画 %s)。", + "debug.needsofnature.post_escape_gather_started": "%s 只附近生物在你挣脱后开始追赶。", + "debug.needsofnature.gather_cancelled": "聚集已取消:%s(%s)。", + "debug.needsofnature.mob_join_no_expanded_match": "%s 无法加入:没有匹配的扩展动画。", + "debug.needsofnature.roll_result": "掷骰 %s(%s%%)→ %s。", + "debug.needsofnature.energy_locked_attack_cooldown": "%s 处于攻击冷却;性欲锁定为 0。", + "debug.needsofnature.attack_blocked_pregnancy_friendly": "阻止直接攻击 %s -> %s(怀孕友好生物)。", + "debug.needsofnature.attack_blocked_gathering_pair": "阻止直接攻击 %s -> %s(双方都在待定/活跃中)。", + "debug.needsofnature.attack_blocked_energy_busy": "阻止直接攻击 %s -> %s(生物性欲 > 70 而玩家动画忙)。", + "debug.needsofnature.no_defeat_tag_animation": "没有匹配玩家的落败标签动画;跳过落败队列。", + "debug.needsofnature.missing_entity_bottle_fallback": "缺少 %s 体液的装瓶映射;使用混合体液瓶。", + "debug.needsofnature.missing_birth_animation": "未找到 %s 的分娩动画;怀孕已清除。", + "debug.needsofnature.missing_birth_cue": "分娩动画 %s 没有“birth”提示;使用停止回退。", + "debug.needsofnature.birth_fallback_spawn": "停止时触发了分娩回退生成(%s)。", + "debug.needsofnature.missing_pregnancy_entity_type": "怀孕生成缺少实体类型 %s。", + "item.needsofnature.entity_liquid_bottle": "%s 体液瓶", + "item.minecraft.potion.effect.needsofnature.liquid": "体液瓶", + "stat.needsofnature.animations_started_total": "NoN:开始的动画", + "stat.needsofnature.peaked_animations_total": "NoN:达到高潮的动画", + "stat.needsofnature.liquid_gained_ml_total": "NoN:获得的体液(ml)", + "stat.needsofnature.multi_actor_animations_total": "NoN:3+ 演员动画", + "stat.needsofnature.water_animations_total": "NoN:水中动画", + "stat.needsofnature.attack_animations_started_total": "NoN:开始的攻击动画", + "stat.needsofnature.attack_escapes_success_total": "NoN:攻击挣脱", + "stat.needsofnature.m_injector_peaks_ml_total": "NoN:M 类注入高潮(ml)", + "stat.needsofnature.manual_peaks_total": "NoN:手动高潮", + "stat.needsofnature.bottles_filled_total": "NoN:装满的瓶子", + "stat.needsofnature.pregnancies_started_total": "NoN:开始的怀孕", + "stat.needsofnature.pregnancies_completed_total": "NoN:完成的怀孕", + "stat.needsofnature.offspring_spawned_total": "NoN:生成的后代", + "advancements.needsofnature.root.title": "自然需求", + "advancements.needsofnature.root.description": "探索自然需求系统的方方面面。", + "advancements.needsofnature.natural_curiosity.title": "自然的好奇", + "advancements.needsofnature.natural_curiosity.description": "达到任意自然需求动画的高潮阶段。", + "advancements.needsofnature.filled_to_the_brim.title": "满到溢出", + "advancements.needsofnature.filled_to_the_brim.description": "完全填满你的体液储槽。", + "advancements.needsofnature.favouritism.title": "专情", + "advancements.needsofnature.favouritism.description": "仅用同一种实体的体液完全填满储槽。", + "advancements.needsofnature.first_pregnancy.title": "第一次怀孕", + "advancements.needsofnature.first_pregnancy.description": "开始你的第一次怀孕。", + "advancements.needsofnature.mother_nature.title": "自然之母", + "advancements.needsofnature.mother_nature.description": "完成 25 次怀孕。", + "advancements.needsofnature.messed_up.title": "一团糟", + "advancements.needsofnature.messed_up.description": "同时达到每种污渍的最大值。", + "advancements.needsofnature.group_activity.title": "团体活动", + "advancements.needsofnature.group_activity.description": "参与由四名或更多演员组成的动画。", + "advancements.needsofnature.tossed_around.title": "被甩来甩去", + "advancements.needsofnature.tossed_around.description": "连续被投入五次落败动画。", + "advancements.needsofnature.collectors_habit.title": "收集者的习惯", + "advancements.needsofnature.collectors_habit.description": "从体液储槽装满 25 个瓶子。", + "advancements.needsofnature.field_research.title": "实地研究", + "advancements.needsofnature.field_research.description": "在同一个游戏日内从五种不同实体获得体液。", + "advancements.needsofnature.bad_idea_beacon.title": "坏主意信标", + "advancements.needsofnature.bad_idea_beacon.description": "在皮肤完全破损时拥有满性欲。", + "advancements.needsofnature.productive_pregnancy.title": "多产怀孕", + "advancements.needsofnature.productive_pregnancy.description": "完成一次产生多于一个后代或蛋的怀孕。", + "advancements.needsofnature.udderly_unfortunate.title": "牛生不幸", + "advancements.needsofnature.udderly_unfortunate.description": "怀孕时喝牛奶。", + "advancements.needsofnature.emergency_snack.title": "应急零食", + "advancements.needsofnature.emergency_snack.description": "饥饿时获得 M 类注入饥饿奖励,或喝下实体体液瓶。", + "advancements.needsofnature.stable_diet.title": "稳定饮食", + "advancements.needsofnature.stable_diet.description": "连续三次触发应急零食,期间不吃不喝其他东西。", + "advancements.needsofnature.acquire_hardware.title": "获得硬件", + "advancements.needsofnature.acquire_hardware.description": "手持物品开始手动高潮动画。", + "gui.needsofnature.repair_skin.needs_loom": "附近需要织布机", + "gui.needsofnature.repair_skin.needs_loom_or_crafting_table": "附近需要织布机或工作台", + "gui.needsofnature.pregnancy_cycle.tooltip": "第 %s/%s 天\n%s", + "gui.needsofnature.player_profiles.title": "玩家交互配置", + "gui.needsofnature.player_profiles.tooltip": "设置你的默认动画请求偏好及针对每位玩家的偏好。", + "gui.needsofnature.player_profiles.default": "其他玩家的默认设置", + "gui.needsofnature.player_profiles.player_name": "玩家名称", + "gui.needsofnature.player_profiles.player_name_hint": "服务器已知的玩家名称", + "gui.needsofnature.player_profiles.apply": "应用", + "gui.needsofnature.player_profiles.unknown_player": "该玩家尚未被此服务器识别。", + "gui.needsofnature.player_profiles.allowed_profiles": "允许的配置", + "gui.needsofnature.player_profiles.allowed_profiles_hint": "至少必须保留一个配置。", + "gui.needsofnature.player_profiles.allowed": "%s:已允许", + "gui.needsofnature.player_profiles.disallowed": "%s:已禁用", + "gui.needsofnature.player_profiles.mode.deny_all": "不接受请求或攻击", + "gui.needsofnature.player_profiles.mode.requests_only": "接受请求,不接受攻击", + "gui.needsofnature.player_profiles.mode.requests_and_attacks": "接受请求与攻击", + "gui.needsofnature.player_profiles.mode.auto_accept": "自动接受请求", + "gui.needsofnature.player_request.select_title": "选择动画", + "gui.needsofnature.player_request.select_for": "请求 %s", + "gui.needsofnature.player_request.select_join_title": "选择加入动画", + "gui.needsofnature.player_request.select_join_for": "选择如何加入 %s", + "gui.needsofnature.player_request.section.available": "可用动画", + "gui.needsofnature.player_request.section.unavailable": "不可用动画", + "gui.needsofnature.player_request.role_title": "选择你的角色", + "gui.needsofnature.player_request.role_option": "你的角色:%s(%s)", + "gui.needsofnature.player_request.role_activity.active": "主动", + "gui.needsofnature.player_request.role_activity.passive": "被动", + "gui.needsofnature.player_request.roles": "%s 个角色选项", + "gui.needsofnature.player_request.consent_title": "动画请求", + "gui.needsofnature.player_request.accept": "接受", + "gui.needsofnature.player_request.reject": "拒绝", + "gui.needsofnature.player_request.start_prompt": "请求动画:%s", + "gui.needsofnature.player_request.join_prompt": "请求加入:%s", + "gui.needsofnature.player_request.waiting_title": "请求已发送", + "gui.needsofnature.player_request.waiting_description": "等待所有玩家接受...", + "gui.needsofnature.player_request.waiting": "等待玩家同意。", + "gui.needsofnature.player_request.started": "动画已开始。", + "gui.needsofnature.player_request.rejected": "动画请求已被拒绝。", + "gui.needsofnature.player_request.cancelled": "动画请求已取消。", + "gui.needsofnature.player_request.timeout": "动画请求已超时。", + "gui.needsofnature.player_request.out_of_range": "玩家距离过远。", + "gui.needsofnature.player_request.damaged": "动画请求因受到伤害而取消。", + "gui.needsofnature.player_request.disconnected": "有玩家断开连接。", + "gui.needsofnature.player_request.player_busy": "玩家正在进行其他动画。", + "gui.needsofnature.player_request.recipient_busy": "被请求的玩家已有其他待处理请求。", + "gui.needsofnature.player_request.not_accepting": "玩家当前不接受请求。", + "gui.needsofnature.player_request.energy_low": "请求动画至少需要 %s 点性欲。", + "gui.needsofnature.player_request.already_pending": "你已有待处理的动画请求。", + "gui.needsofnature.player_request.no_match": "没有符合条件的动画匹配这些角色。", + "gui.needsofnature.player_request.block_requirement_unavailable": "所需方块不可用", + "gui.needsofnature.player_request.block_requirement_summary": "需要:%s", + "gui.needsofnature.player_request.block_requirement_tooltip": "此动画无法在此开始,因为其方块条件不可用:%s", + "gui.needsofnature.player_request.animation_changed": "所选动画已不再符合条件。", + "gui.needsofnature.player_request.protected": "已装备的保护器阻止了该动画。", + "gui.needsofnature.player_request.invalid": "动画请求已失效。", + "config.needsofnature.category.credits": "鸣谢", + "config.needsofnature.credits_title": "Needs Of Nature 鸣谢", + "config.needsofnature.credits_intro": "Needs Of Nature 的诞生离不开:", + "config.needsofnature.credits.resource_credits": "资源鸣谢", + "config.needsofnature.credits.project_links": "项目链接", + "config.needsofnature.credits.category_value": "分类:%s", + "config.needsofnature.credits.creator_value": "作者:%s", + "config.needsofnature.credits.source_value": "来源:%s", + "config.needsofnature.credits.license_value": "许可:%s", + "config.needsofnature.credits.license_modified_value": "许可:%s | 已修改", + "config.needsofnature.credits.assets_value": "资源:%s", + "config.needsofnature.credits.modified": "已修改", + "config.needsofnature.credits.source": "来源", + "config.needsofnature.credits.license": "许可", + "config.needsofnature.loaded_animations.load_order.move_up": "上移数据包", + "config.needsofnature.loaded_animations.load_order.move_down": "下移数据包", + "config.needsofnature.loaded_animations.disabled_count": "%s 个已关闭", + "config.needsofnature.loaded_animations.details": "详情", + "config.needsofnature.destroyed_skin_female_gender_overrides": "用受损皮肤配置覆盖 FGM 设置:", + "config.needsofnature.destroyed_skin_female_gender_profiles": "胸部设置...", + "config.needsofnature.destroyed_skin_female_gender_profiles_title": "胸部设置", + "config.needsofnature.destroyed_skin_female_gender_profile.low": "受损阶段 0-1", + "config.needsofnature.destroyed_skin_female_gender_profile.high": "受损阶段 2-4", + "config.needsofnature.open_non_player_preferences": "打开 NoN 玩家偏好", + "config.needsofnature.intiface_latency_compensation": "延迟补偿:", + "config.needsofnature.section.intiface_timing": "时机", + "config.needsofnature.section.player_requests": "玩家请求", + "config.needsofnature.section.player_attack_balance": "玩家攻击平衡", + "config.needsofnature.section.mount_protection": "坐骑保护", + "config.needsofnature.section.liquid_based_pregnancy": "基于液体的怀孕", + "config.needsofnature.section.ripped_skin_repair": "破损皮肤修复", + "config.needsofnature.section.death_persistence": "死亡后保留", + "config.needsofnature.section.animation_runtime": "动画行为", + "config.needsofnature.keep_mess_after_death": "死亡后保留污渍:", + "config.needsofnature.keep_ripped_skin_after_death": "死亡后保留破损皮肤:", + "config.needsofnature.keep_liquid_tank_after_death": "死亡后保留液体槽:", + "config.needsofnature.keep_pregnancy_after_death": "死亡后继续怀孕:", + "config.needsofnature.allow_crafting_table_skin_repair": "允许工作台修复:", + "config.needsofnature.player_request_min_energy": "请求动画所需最低性欲(0-200):", + "config.needsofnature.attack_escape_damage_difficulty_percent": "逃脱生命值惩罚(0-200%):", + "config.needsofnature.pvp_full_health_escape_bonus_percent": "满血逃脱加成(0-200%):", + "config.needsofnature.pvp_critical_health_mash_strength_percent": "残血挣扎强度(0-200%):", + "config.needsofnature.pvp_max_heat_escape_strength_percent": "最大热度逃脱强度(0-200%):", + "config.needsofnature.pvp_zero_heat_attack_strength_percent": "零热度攻击强度(0-200%):", + "config.needsofnature.attack_escape_invulnerability_seconds": "逃脱伤害无敌时间(秒)(0-10):", + "config.needsofnature.attack_escape_animation_protection_seconds": "逃脱后动画保护时间(秒)(0-10):", + "config.needsofnature.block_animations_while_riding_living_entities": "保护生物坐骑:", + "config.needsofnature.block_animations_while_riding_vehicles": "保护载具:", + "config.needsofnature.liquid_based_pregnancy": "基于液体的怀孕:", + "config.needsofnature.pregnancy_risky_start_day": "危险阶段开始(第 1-28 天):", + "config.needsofnature.pregnancy_ovulation_end_day": "排卵期结束(第 3-30 天):", + "config.needsofnature.pregnancy_safe_day_chance_percent": "安全日概率倍率(%):", + "config.needsofnature.pregnancy_risky_chance_percent": "危险日概率倍率(%):", + "config.needsofnature.pregnancy_ovulation_chance_percent": "排卵日概率倍率(%):", + "config.needsofnature.pregnancy_cycle.safe": "安全日", + "config.needsofnature.pregnancy_cycle.normal": "普通日", + "config.needsofnature.pregnancy_cycle.risky": "危险日", + "config.needsofnature.pregnancy_cycle.ovulation": "排卵期", + "config.needsofnature.entity_profile_column.egg": "蛋", + "config.needsofnature.entity_profile_egg_button": "蛋", + "config.needsofnature.egg_profile_title": "蛋设置", + "config.needsofnature.egg_profile_start_size": "初始尺寸", + "config.needsofnature.egg_profile_end_size": "孵化尺寸", + "config.needsofnature.egg_profile_texture": "纹理", + "config.needsofnature.egg_profile_health": "生命值", + "config.needsofnature.reset": "重置", + "config.needsofnature.tooltip.category.credits": "查看项目贡献者、资源鸣谢与项目链接。", + "config.needsofnature.tooltip.female_gender.breast_texture_editor": "打开女性性别模组共享的胸部纹理编辑器。", + "config.needsofnature.tooltip.wildfire_gender_controlled_by_non": "在 Needs Of Nature 玩家偏好中更改性别。", + "config.needsofnature.tooltip.wildfire_personalization_controlled_by_non": "在 Needs Of Nature 中更改胸部设置。", + "config.needsofnature.tooltip.open_non_player_preferences": "打开 Needs Of Nature 玩家偏好。", + "config.needsofnature.tooltip.intiface_latency_compensation": "提前这么多毫秒发送可预测的动画和心跳玩具指令。动画开始无法在收到前预判;停止和断开指令仍即时发送。", + "config.needsofnature.tooltip.female_gender.breast_size_extended": "超过 100% 的值在某些条件下可能看起来破损或被裁剪。", + "config.needsofnature.tooltip.keep_mess_after_death": "启用后,玩家污渍数据在死亡和重生后保留。", + "config.needsofnature.tooltip.keep_ripped_skin_after_death": "启用后,破损皮肤的伤害和阶段在死亡和重生后保留。", + "config.needsofnature.tooltip.keep_liquid_tank_after_death": "启用后,液体量和成分在死亡和重生后保留。", + "config.needsofnature.tooltip.keep_pregnancy_after_death": "启用后,怀孕状态在死亡后保留并在重生后继续。", + "config.needsofnature.tooltip.allow_crafting_table_skin_repair": "启用后,工作台也可修复破损衣物。织布机始终可用。", + "config.needsofnature.tooltip.player_request_min_energy": "请求新动画或加入现有动画所需的最低玩家性欲。", + "config.needsofnature.tooltip.attack_escape_damage_difficulty_percent": "低生命值对怪物的逃脱进度有多大的削弱作用。0% 禁用生命值惩罚,100% 为正常,200% 更严苛。", + "config.needsofnature.tooltip.pvp_full_health_escape_bonus_percent": "在 75% 与满血之间获得额外的 PvP 逃脱进度。30% 时,满血防守方的挣扎强度为 1.30 倍。", + "config.needsofnature.tooltip.pvp_critical_health_mash_strength_percent": "PvP 零生命值时的挣扎强度,在 25% 生命值时恢复为正常强度。对攻击方和防守方同样适用。", + "config.needsofnature.tooltip.pvp_max_heat_escape_strength_percent": "PvP 防守方在最大性欲时的挣扎强度。性欲低于 60% 时无逃脱惩罚。", + "config.needsofnature.tooltip.pvp_zero_heat_attack_strength_percent": "PvP 攻击方在零性欲时的挣扎强度,在最大性欲时恢复为正常强度。", + "config.needsofnature.tooltip.attack_escape_invulnerability_seconds": "施加逃脱伤害后,玩家免受普通伤害的时间。", + "config.needsofnature.tooltip.attack_escape_animation_protection_seconds": "逃脱攻击动画后,阻止新动画开始的时间。", + "config.needsofnature.tooltip.block_animations_while_riding_living_entities": "启用后,玩家骑乘生物坐骑(如马、猪或炽足兽)时,跳过自动的 NoN 生物-玩家动画开始。", + "config.needsofnature.tooltip.block_animations_while_riding_vehicles": "启用后,玩家乘坐非生物载具(如船或矿车)时,跳过自动的 NoN 生物-玩家动画开始。", + "config.needsofnature.tooltip.liquid_based_pregnancy": "使用存储的液体和玩家的 30 天周期,而不是在峰值时直接判定怀孕。混合液体随机使用最近三位非玩家供体之一。", + "config.needsofnature.tooltip.pregnancy_risky_start_day": "30 天怀孕周期的第一个危险日。", + "config.needsofnature.tooltip.pregnancy_ovulation_end_day": "最后一个排卵日。排卵日始终包含这一天及前一天。", + "config.needsofnature.tooltip.pregnancy_safe_day_chance_percent": "安全阶段的怀孕概率倍率。10% 等于 0.1 倍。", + "config.needsofnature.tooltip.pregnancy_risky_chance_percent": "危险阶段的怀孕概率倍率。300% 等于 3 倍。", + "config.needsofnature.tooltip.pregnancy_ovulation_chance_percent": "两天排卵期的怀孕概率倍率。500% 等于 5 倍。", + "config.needsofnature.tooltip.animation.auto_switch_third_person": "本地玩家处于动画中时,临时切换到第三人称视角。", + "config.needsofnature.tooltip.loaded_animations.load_order.move_up": "将此数据包上移一位。", + "config.needsofnature.tooltip.loaded_animations.load_order.move_down": "将此数据包下移一位。", + "config.needsofnature.tooltip.entity_profile_entry_egg": "打开此源实体的蛋设置。", + "config.needsofnature.tooltip.egg_profile_start_size": "生成时蛋的渲染与碰撞箱比例。", + "config.needsofnature.tooltip.egg_profile_end_size": "孵化时蛋的渲染与碰撞箱比例。", + "config.needsofnature.tooltip.egg_profile_texture": "可选完整纹理 ID,例如 needsofnature:textures/entity/pregnancy_egg/default.png。", + "config.needsofnature.tooltip.egg_profile_health": "蛋的生命值(以 Minecraft 原始生命值计)。", + "config.needsofnature.tooltip.egg_profile_reset": "将这些蛋字段重置为当前 NoN 数据包的默认值。", + "debug.needsofnature.player_join_accepted": "已加入动画(动画 %s)。", + "debug.needsofnature.player_join_failed": "无法加入动画(动画 %s)。", + "debug.needsofnature.player_join_no_expanded_match": "无法加入:没有匹配的扩展动画。", + "debug.needsofnature.player_join_denied_busy": "无法加入:你已处于动画中。", + "debug.needsofnature.mounted_animation_blocked": "动画已跳过:你正骑乘 %s。", + "debug.needsofnature.animation.stop_all_broadcast_queued": "[NoN 动画] 已排队广播停止全部。", + "debug.needsofnature.animation.server_eval": "[NoN 动画] 服务器评估:角色=%s 选择=%s 键=%s", + "debug.needsofnature.animation.start_received": "[NoN 动画] 开始 %s 角色=%s 键=%s", + "debug.needsofnature.animation.stop_all_received": "[NoN 动画] 停止全部", + "debug.needsofnature.animation.stop_instance_received": "[NoN 动画] 停止实例", + "debug.needsofnature.animation.server_cannot_receive_stop_all": "[NoN 动画] 服务器无法接收停止全部数据包(服务端未安装本模组)。", + "debug.needsofnature.animation.stop_all_requested": "[NoN 动画] 已请求停止全部。", + "debug.needsofnature.animation.no_active_instance_to_stop": "[NoN 动画] 没有可停止的活动实例。", + "debug.needsofnature.animation.server_cannot_receive_stop_instance": "[NoN 动画] 服务器无法接收停止实例数据包(服务端未安装本模组)。", + "debug.needsofnature.animation.stop_instance_requested": "[NoN 动画] 已请求停止实例。", + "debug.needsofnature.animation.no_entity_targeted": "[NoN 动画] 未选中实体。", + "debug.needsofnature.animation.target_not_living": "[NoN 动画] 目标不是生物实体。", + "debug.needsofnature.animation.target_not_mob": "[NoN 动画] 目标不是怪物。", + "debug.needsofnature.animation.animation_deselected": "[NoN 动画] 已取消选择动画。", + "debug.needsofnature.animation.animation_selected_add_actors_for_join": "[NoN 动画] 已选择动画。添加额外角色后按开始即可加入。", + "debug.needsofnature.animation.entity_deselected_actor_count": "[NoN 动画] 实体:%s 已取消选择,角色 %s", + "debug.needsofnature.animation.entity_selected_actor_count": "[NoN 动画] 实体:%s 已选择,角色 %s", + "debug.needsofnature.animation.self_deselected_actor_count": "[NoN 动画] 已取消选择自己,角色 %s", + "debug.needsofnature.animation.self_selected_actor_count": "[NoN 动画] 已选择自己,角色 %s", + "debug.needsofnature.animation.no_actors_selected": "[NoN 动画] 未选择角色。", + "debug.needsofnature.animation.no_extra_actors_for_join": "[NoN 动画] 未选择用于加入的额外角色。", + "debug.needsofnature.animation.server_cannot_receive_join": "[NoN 动画] 服务器无法接收加入数据包(服务端未安装本模组)。", + "debug.needsofnature.animation.join_requested_instance_extras": "[NoN 动画] 已请求加入。实例=%s 额外角色=%s", + "debug.needsofnature.animation.server_cannot_receive_debug_packets": "[NoN 动画] 服务器无法接收调试数据包(服务端未安装本模组)。", + "debug.needsofnature.animation.start_requested_actor_count": "[NoN 动画] 已请求开始(H)。角色=%s", + "debug.needsofnature.animation.no_active_instance_to_advance": "[NoN 动画] 没有可推进的活动实例。", + "debug.needsofnature.animation.next_stage_requested_server": "[NoN 动画] 已请求下一阶段(服务器)。", + "debug.needsofnature.animation.next_stage_requested_local": "[NoN 动画] 已请求下一阶段(仅本地)。", + "debug.needsofnature.animation.no_active_instance_to_adjust_speed": "[NoN 动画] 没有可调整速度的活动实例。", + "debug.needsofnature.animation.speed_change_requested": "[NoN 动画] 已请求速度更改(%s)。", + "debug.needsofnature.animation.server_cannot_receive_speed": "[NoN 动画] 服务器无法接收速度数据包(服务端未安装本模组)。", + "debug.needsofnature.animation.stage_sequence_ended_server_stop_unavailable": "[NoN 动画] 阶段序列已在本地结束;服务器停止不可用。", + "debug.needsofnature.animation.queue_enqueue_failed_exactly_one_player": "[NoN 动画] 排队失败:必须恰好有一位玩家角色。", + "debug.needsofnature.animation.queue_enqueue_failed_full": "[NoN 动画] 排队失败:队列已满(上限=%s)。", + "debug.needsofnature.animation.queue_enqueue_failed_player_missing": "[NoN 动画] 排队失败:缺少玩家角色。", + "debug.needsofnature.animation.queue_enqueue_denied_mob_already_queued": "[NoN 动画] 排队被拒绝:%s 已为另一位玩家排队。", + "debug.needsofnature.animation.queued_at_position": "[NoN 动画] %s 已排队至第 %s 位。", + "debug.needsofnature.animation.queue_cleared_for_player": "[NoN 动画] 玩家的队列已清空。", + "debug.needsofnature.animation.queue_cleared_for_active_instance": "[NoN 动画] 活动实例的队列已清空。", + "debug.needsofnature.animation.matched_unpathable_actor": "[NoN 动画] 匹配到 %s,但角色无法寻路(不是怪物/玩家)。", + "debug.needsofnature.animation.matched_multiple_players_not_supported": "[NoN 动画] 匹配到 %s,但暂不支持多位玩家。", + "debug.needsofnature.animation.matched_multiplayer_direct_start": "[NoN 动画] 匹配到 %s 与角色 %s -> 直接多人调试开始。", + "debug.needsofnature.animation.start_cancelled_block_and_water_requirement": "[NoN 动画] 开始取消:方块条件不能与水中条件同时使用。", + "debug.needsofnature.animation.start_cancelled_no_valid_wall": "[NoN 动画] 开始取消:找不到有效的墙体放置位置(%s,角色:%s)。", + "debug.needsofnature.animation.start_cancelled_no_valid_center_support": "[NoN 动画] 开始取消:找不到有效的中心支撑位置(%s,角色:%s)。", + "debug.needsofnature.animation.start_cancelled_players_too_far_from_requester": "[NoN 动画] 开始取消:所选玩家必须位于请求者 %s 格范围内。", + "debug.needsofnature.animation.join_failed_no_active_instance": "[NoN 动画] 加入失败:没有此活动实例。", + "debug.needsofnature.animation.join_denied_stage_disallows": "[NoN 动画] 加入被拒绝:当前阶段不允许加入。", + "debug.needsofnature.animation.join_failed_players_not_supported": "[NoN 动画] 加入失败:加入不支持玩家角色。", + "debug.needsofnature.animation.join_failed_no_expanded_definition": "[NoN 动画] 加入失败:扩展角色集没有对应定义。", + "debug.needsofnature.animation.no_active_instance_to_adjust": "[NoN 动画] 没有可调整的活动实例。", + "debug.needsofnature.animation.speed_unchanged": "[NoN 动画] 速度未改变。", + "debug.needsofnature.animation.speed_set": "[NoN 动画] 速度已设为 %s", + "debug.needsofnature.animation.no_such_active_instance": "[NoN 动画] 没有此活动实例。", + "debug.needsofnature.animation.instance_in_different_world": "[NoN 动画] 实例位于不同世界;无法从此处停止。", + "debug.needsofnature.animation.broadcasting_stop": "[NoN 动画] 正在广播停止", + "debug.needsofnature.animation.queue_continued_for_player": "[NoN 动画] 玩家的队列已继续。", + "debug.needsofnature.animation.queue_cleared": "[NoN 动画] 队列已清空。", + "debug.needsofnature.animation.start_cancelled_water_requirement_not_met": "[NoN 动画] 开始取消:未满足水中条件。", + "debug.needsofnature.animation.start_cancelled_not_allowed_in_water": "[NoN 动画] 开始取消:动画不允许在水中。", + "debug.needsofnature.animation.start_cancelled_not_enough_space": "[NoN 动画] 开始取消:%s 的空间不足(角色:%s)。", + "debug.needsofnature.animation.actors_immediate_broadcast_start": "[NoN 动画] 立即开始。正在广播开始", + "debug.needsofnature.animation.queue_skipped_player_unavailable": "[NoN 动画] %s 的队列已跳过(角色:%s):玩家不可用。", + "debug.needsofnature.animation.queue_skipped_missing_non_player_actor": "[NoN 动画] %s 的队列已跳过(角色:%s):缺少非玩家角色。", + "debug.needsofnature.animation.queue_skipped_no_actors_resolved": "[NoN 动画] %s 的队列已跳过(角色:%s):未解析出角色。", + "debug.needsofnature.animation.queue_skipped_missing_definition": "[NoN 动画] %s 的队列已跳过(角色:%s):缺少定义。", + "debug.needsofnature.animation.queue_skipped_requirements_not_met": "[NoN 动画] %s 的队列已跳过(角色:%s):未满足条件。", + "debug.needsofnature.animation.queue_skipped_start_failed": "[NoN 动画] %s 的队列已跳过(角色:%s):开始失败。", + "debug.needsofnature.animation.queued_animation_started": "[NoN 动画] 已排队的动画开始:%s(角色:%s)", + "key.categories.needsofnature.animation": "NeedsOfNature 动画调试", + "key.category.needsofnature.animation": "NeedsOfNature 动画调试", + "key.needsofnature.animation.select_actor": "选择角色(调试)", + "key.needsofnature.animation.select_self": "选择自己(调试)", + "key.needsofnature.animation.start_debug_animation": "开始动画(调试)", + "key.needsofnature.animation.stop_all_debug": "停止全部动画(调试)", + "key.needsofnature.animation.stop_instance_debug": "停止动画实例(调试)", + "key.needsofnature.animation.next_stage_debug": "下一阶段(调试)", + "key.needsofnature.animation.speed_up_debug": "加速动画(调试)", + "key.needsofnature.animation.speed_down_debug": "减速动画(调试)", + "config.needsofnature.animation.title": "NeedsOfNature 动画", + "config.needsofnature.animation.force_vanilla_entity_textures": "强制使用原版实体纹理(忽略资源包)", + "config.needsofnature.animation.block_search_radius": "方块搜索半径", + "config.needsofnature.animation.auto_switch_third_person": "动画开始时自动切换到第三人称", + "config.needsofnature.animation.debug_menu": "调试选项", + "config.needsofnature.animation.open": "打开", + "config.needsofnature.animation.debug_title": "NeedsOfNature 动画调试", + "config.needsofnature.animation.debug_chat_mode": "调试聊天", + "config.needsofnature.animation.debug_chat_mode.all": "全部", + "config.needsofnature.animation.debug_chat_mode.setup_warnings_errors": "警告、设置、错误", + "config.needsofnature.animation.debug_chat_mode.setup_errors": "设置、错误", + "config.needsofnature.animation.debug_chat_mode.errors_only": "仅错误", + "config.needsofnature.animation.reset_icon": "<", + "config.jade.plugin_needsofnature.gender": "生物性别", + "config.needsofnature.server_settings.no_permission": "你没有更改服务器设置的权限。", + "config.needsofnature.server_settings.stale": "此界面打开时服务器设置已更改。请重新打开并重试。", + "config.needsofnature.server_settings.invalid": "服务器拒绝了设置更新。" +} \ No newline at end of file diff --git a/src/main/resources/assets/needsofnature/models/item/energy_augmenter.json b/src/main/resources/assets/needsofnature/models/item/energy_augmenter.json new file mode 100644 index 0000000..a2e7f37 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_augmenter.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_augmenter" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_diminisher.json b/src/main/resources/assets/needsofnature/models/item/energy_diminisher.json new file mode 100644 index 0000000..2a6b6d6 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_diminisher.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_diminisher" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_lingering_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_lingering_potion.json new file mode 100644 index 0000000..7665662 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_lingering_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_potion_overlay", + "layer1": "minecraft:item/lingering_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_potion.json new file mode 100644 index 0000000..fc0ccbd --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_potion_overlay", + "layer1": "minecraft:item/potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_relief_lingering_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_relief_lingering_potion.json new file mode 100644 index 0000000..1f217bc --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_relief_lingering_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_relief_potion_overlay", + "layer1": "minecraft:item/lingering_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_relief_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_relief_potion.json new file mode 100644 index 0000000..49fa5ae --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_relief_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_relief_potion_overlay", + "layer1": "minecraft:item/potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_relief_splash_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_relief_splash_potion.json new file mode 100644 index 0000000..6b3d107 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_relief_splash_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_relief_potion_overlay", + "layer1": "minecraft:item/splash_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_relief_tipped_arrow.json b/src/main/resources/assets/needsofnature/models/item/energy_relief_tipped_arrow.json new file mode 100644 index 0000000..60ad37c --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_relief_tipped_arrow.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_relief_tipped_arrow_head", + "layer1": "minecraft:item/tipped_arrow_base" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_splash_potion.json b/src/main/resources/assets/needsofnature/models/item/energy_splash_potion.json new file mode 100644 index 0000000..c66e258 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_splash_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_potion_overlay", + "layer1": "minecraft:item/splash_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_stabilizer.json b/src/main/resources/assets/needsofnature/models/item/energy_stabilizer.json new file mode 100644 index 0000000..2915378 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_stabilizer.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_stabilizer" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/energy_tipped_arrow.json b/src/main/resources/assets/needsofnature/models/item/energy_tipped_arrow.json new file mode 100644 index 0000000..e5d4e02 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/energy_tipped_arrow.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/energy_tipped_arrow_head", + "layer1": "minecraft:item/tipped_arrow_base" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertile_nectar_lingering_potion.json b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_lingering_potion.json new file mode 100644 index 0000000..6e4e49a --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_lingering_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertile_nectar_overlay", + "layer1": "minecraft:item/lingering_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertile_nectar_potion.json b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_potion.json new file mode 100644 index 0000000..a291bd8 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertile_nectar_overlay", + "layer1": "minecraft:item/potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertile_nectar_splash_potion.json b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_splash_potion.json new file mode 100644 index 0000000..42d1736 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertile_nectar_splash_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertile_nectar_overlay", + "layer1": "minecraft:item/splash_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertility_lingering_potion.json b/src/main/resources/assets/needsofnature/models/item/fertility_lingering_potion.json new file mode 100644 index 0000000..0d5a65d --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertility_lingering_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertility_potion_overlay", + "layer1": "minecraft:item/lingering_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertility_potion.json b/src/main/resources/assets/needsofnature/models/item/fertility_potion.json new file mode 100644 index 0000000..a68a691 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertility_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertility_potion_overlay", + "layer1": "minecraft:item/potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/fertility_splash_potion.json b/src/main/resources/assets/needsofnature/models/item/fertility_splash_potion.json new file mode 100644 index 0000000..527189b --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/fertility_splash_potion.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/fertility_potion_overlay", + "layer1": "minecraft:item/splash_potion" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/flower_mix.json b/src/main/resources/assets/needsofnature/models/item/flower_mix.json new file mode 100644 index 0000000..0b1b1cc --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/flower_mix.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "needsofnature:item/flower_mix" + } +} diff --git a/src/main/resources/assets/needsofnature/models/item/horse_liquid_collector.json b/src/main/resources/assets/needsofnature/models/item/horse_liquid_collector.json new file mode 100644 index 0000000..e4a4763 --- /dev/null +++ b/src/main/resources/assets/needsofnature/models/item/horse_liquid_collector.json @@ -0,0 +1,43 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "minecraft:block/barrel_side", + "particle": "minecraft:block/barrel_side" + }, + "display": { + "thirdperson_righthand": { + "translation": [0, -3.25, 0], + "scale": [0.6, 0.6, 0.6] + }, + "thirdperson_lefthand": { + "translation": [0, -3.5, 0], + "scale": [0.6, 0.6, 0.6] + }, + "firstperson_righthand": { + "translation": [0, -5, 2.5], + "scale": [0.6, 0.6, 0.6] + }, + "firstperson_lefthand": { + "translation": [0, -5, 2.5], + "scale": [0.6, 0.6, 0.6] + }, + "ground": { + "translation": [0, -2, 0], + "scale": [0.6, 0.6, 0.6] + }, + "gui": { + "rotation": [23, 140, 0], + "translation": [0, -6.5, 0], + "scale": [0.6, 0.6, 0.6] + }, + "fixed": { + "rotation": [0, -90, 0], + "translation": [0, -8.25, 0], + "scale": [0.6, 0.6, 0.6] + }, + "on_shelf": { + "rotation": [0, -180, 0], + "translation": [0, -2, 0] + } + } +} diff --git a/src/main/resources/assets/needsofnature/particles/liquid_particle.json b/src/main/resources/assets/needsofnature/particles/liquid_particle.json new file mode 100644 index 0000000..4869817 --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/liquid_particle.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:liquid_drip_hang" + ] +} diff --git a/src/main/resources/assets/needsofnature/particles/liquid_particle_falling.json b/src/main/resources/assets/needsofnature/particles/liquid_particle_falling.json new file mode 100644 index 0000000..155f302 --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/liquid_particle_falling.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:liquid_drip_fall" + ] +} diff --git a/src/main/resources/assets/needsofnature/particles/liquid_particle_puddle.json b/src/main/resources/assets/needsofnature/particles/liquid_particle_puddle.json new file mode 100644 index 0000000..40a56c0 --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/liquid_particle_puddle.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:liquid_puddle" + ] +} diff --git a/src/main/resources/assets/needsofnature/particles/liquid_particle_water.json b/src/main/resources/assets/needsofnature/particles/liquid_particle_water.json new file mode 100644 index 0000000..1182831 --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/liquid_particle_water.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:liquid_drip_water" + ] +} diff --git a/src/main/resources/assets/needsofnature/particles/ripped_fabric.json b/src/main/resources/assets/needsofnature/particles/ripped_fabric.json new file mode 100644 index 0000000..03952fc --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/ripped_fabric.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:ripped_fabric" + ] +} diff --git a/src/main/resources/assets/needsofnature/particles/smallheart.json b/src/main/resources/assets/needsofnature/particles/smallheart.json new file mode 100644 index 0000000..2611174 --- /dev/null +++ b/src/main/resources/assets/needsofnature/particles/smallheart.json @@ -0,0 +1,5 @@ +{ + "textures": [ + "needsofnature:smallheart" + ] +} diff --git a/src/main/resources/assets/needsofnature/sounds.json b/src/main/resources/assets/needsofnature/sounds.json new file mode 100644 index 0000000..74dbd7b --- /dev/null +++ b/src/main/resources/assets/needsofnature/sounds.json @@ -0,0 +1,208 @@ +{ + "funkytown": { + "sounds": [ + { + "name": "needsofnature:funkytown", + "stream": true + } + ] + }, + "rip01": { + "sounds": ["needsofnature:rip01"] + }, + "rip02": { + "sounds": ["needsofnature:rip02"] + }, + "rip03": { + "sounds": ["needsofnature:rip03"] + }, + "shot_in01": { + "sounds": ["needsofnature:actionsounds/shots/shot_in01"] + }, + "pop": { + "sounds": ["needsofnature:pop"] + }, + "a": { + "sounds": ["needsofnature:a"] + }, + "shot_in02": { + "sounds": ["needsofnature:actionsounds/shots/shot_in02"] + }, + "shot_in03": { + "sounds": ["needsofnature:actionsounds/shots/shot_in03"] + }, + "shot_out01": { + "sounds": ["needsofnature:actionsounds/shots/shot_out01"] + }, + "shot_out02": { + "sounds": ["needsofnature:actionsounds/shots/shot_out02"] + }, + "shot_out03": { + "sounds": ["needsofnature:actionsounds/shots/shot_out03"] + }, + "shot_out04": { + "sounds": ["needsofnature:actionsounds/shots/shot_out04"] + }, + "shot_out05": { + "sounds": ["needsofnature:actionsounds/shots/shot_out05"] + }, + "shot_out06": { + "sounds": ["needsofnature:actionsounds/shots/shot_out06"] + }, + "motion01": { + "sounds": ["needsofnature:actionsounds/motions/motion01"] + }, + "motion02": { + "sounds": ["needsofnature:actionsounds/motions/motion02"] + }, + "motion03": { + "sounds": ["needsofnature:actionsounds/motions/motion03"] + }, + "motion04": { + "sounds": ["needsofnature:actionsounds/motions/motion04"] + }, + "motion05": { + "sounds": ["needsofnature:actionsounds/motions/motion05"] + }, + "motion06": { + "sounds": ["needsofnature:actionsounds/motions/motion06"] + }, + "motion07": { + "sounds": ["needsofnature:actionsounds/motions/motion07"] + }, + "motion08": { + "sounds": ["needsofnature:actionsounds/motions/motion08"] + }, + "motion09": { + "sounds": ["needsofnature:actionsounds/motions/motion09"] + }, + "motion10": { + "sounds": ["needsofnature:actionsounds/motions/motion10"] + }, + "retract01": { + "sounds": ["needsofnature:actionsounds/retract/retract01"] + }, + "retract02": { + "sounds": ["needsofnature:actionsounds/retract/retract02"] + }, + "retract03": { + "sounds": ["needsofnature:actionsounds/retract/retract03"] + }, + "impactdry01": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry01"] + }, + "impactdry02": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry02"] + }, + "impactdry03": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry03"] + }, + "impactdry04": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry04"] + }, + "impactdry05": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry05"] + }, + "impactdry06": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry06"] + }, + "impactdry07": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry07"] + }, + "impactdry08": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry08"] + }, + "impactdry09": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry09"] + }, + "impactdry10": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry10"] + }, + "impactdry11": { + "sounds": ["needsofnature:actionsounds/dryimpacts/impactdry11"] + }, + "impactwet01": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet01"] + }, + "impactwet02": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet02"] + }, + "impactwet03": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet03"] + }, + "impactwet04": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet04"] + }, + "impactwet05": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet05"] + }, + "impactwet06": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet06"] + }, + "impactwet07": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet07"] + }, + "impactwet08": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet08"] + }, + "impactwet09": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet09"] + }, + "impactwet10": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet10"] + }, + "impactwet11": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet11"] + }, + "impactwet12": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet12"] + }, + "impactwet13": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet13"] + }, + "impactwet14": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet14"] + }, + "impactwet15": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet15"] + }, + "impactwet16": { + "sounds": ["needsofnature:actionsounds/wetimpacts/impactwet16"] + }, + "wet01": { + "sounds": ["needsofnature:actionsounds/wet/wet01"] + }, + "wet02": { + "sounds": ["needsofnature:actionsounds/wet/wet02"] + }, + "wet03": { + "sounds": ["needsofnature:actionsounds/wet/wet03"] + }, + "wet04": { + "sounds": ["needsofnature:actionsounds/wet/wet04"] + }, + "wet05": { + "sounds": ["needsofnature:actionsounds/wet/wet05"] + }, + "wet06": { + "sounds": ["needsofnature:actionsounds/wet/wet06"] + }, + "wet07": { + "sounds": ["needsofnature:actionsounds/wet/wet07"] + }, + "wet08": { + "sounds": ["needsofnature:actionsounds/wet/wet08"] + }, + "wet09": { + "sounds": ["needsofnature:actionsounds/wet/wet09"] + }, + "wet10": { + "sounds": ["needsofnature:actionsounds/wet/wet10"] + }, + "wet11": { + "sounds": ["needsofnature:actionsounds/wet/wet11"] + }, + "wet12": { + "sounds": ["needsofnature:actionsounds/wet/wet12"] + } +} diff --git a/src/main/resources/assets/needsofnature/sounds/a.ogg b/src/main/resources/assets/needsofnature/sounds/a.ogg new file mode 100644 index 0000000..7754c5f Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/a.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry01.ogg new file mode 100644 index 0000000..6896ef8 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry02.ogg new file mode 100644 index 0000000..0bbdb3b Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry03.ogg new file mode 100644 index 0000000..477759d Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry04.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry04.ogg new file mode 100644 index 0000000..bb18579 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry04.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry05.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry05.ogg new file mode 100644 index 0000000..26e35e2 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry05.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry06.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry06.ogg new file mode 100644 index 0000000..611fc57 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry06.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry07.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry07.ogg new file mode 100644 index 0000000..e1fd3ba Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry07.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry08.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry08.ogg new file mode 100644 index 0000000..264e0f6 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry08.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry09.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry09.ogg new file mode 100644 index 0000000..354fc96 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry09.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry10.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry10.ogg new file mode 100644 index 0000000..cff9f1d Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry10.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry11.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry11.ogg new file mode 100644 index 0000000..a3a0cb0 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/dryimpacts/impactdry11.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion01.ogg new file mode 100644 index 0000000..314be29 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion02.ogg new file mode 100644 index 0000000..b9aa8f1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion03.ogg new file mode 100644 index 0000000..0c08b4d Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion04.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion04.ogg new file mode 100644 index 0000000..e10e3b6 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion04.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion05.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion05.ogg new file mode 100644 index 0000000..15da7a9 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion05.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion06.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion06.ogg new file mode 100644 index 0000000..df6146b Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion06.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion07.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion07.ogg new file mode 100644 index 0000000..504ab0d Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion07.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion08.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion08.ogg new file mode 100644 index 0000000..e1fe756 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion08.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion09.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion09.ogg new file mode 100644 index 0000000..1160596 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion09.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion10.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion10.ogg new file mode 100644 index 0000000..56e6127 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/motions/motion10.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract01.ogg new file mode 100644 index 0000000..1e44802 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract02.ogg new file mode 100644 index 0000000..e229e52 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract03.ogg new file mode 100644 index 0000000..e2c18c3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/retract/retract03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in01.ogg new file mode 100644 index 0000000..8e5cdad Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in02.ogg new file mode 100644 index 0000000..99947bb Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in03.ogg new file mode 100644 index 0000000..8fa9dd2 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_in03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out01.ogg new file mode 100644 index 0000000..d22f5a3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out02.ogg new file mode 100644 index 0000000..293e1f2 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out03.ogg new file mode 100644 index 0000000..f2eebd1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out04.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out04.ogg new file mode 100644 index 0000000..684556a Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out04.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out05.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out05.ogg new file mode 100644 index 0000000..0deb6dd Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out05.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out06.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out06.ogg new file mode 100644 index 0000000..9c80010 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/shots/shot_out06.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet01.ogg new file mode 100644 index 0000000..db85d9b Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet02.ogg new file mode 100644 index 0000000..e023a81 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet03.ogg new file mode 100644 index 0000000..9d23f00 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet04.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet04.ogg new file mode 100644 index 0000000..2861515 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet04.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet05.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet05.ogg new file mode 100644 index 0000000..6787065 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet05.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet06.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet06.ogg new file mode 100644 index 0000000..00f9f88 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet06.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet07.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet07.ogg new file mode 100644 index 0000000..241281c Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet07.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet08.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet08.ogg new file mode 100644 index 0000000..c57dceb Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet08.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet09.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet09.ogg new file mode 100644 index 0000000..84cdc02 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet09.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet10.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet10.ogg new file mode 100644 index 0000000..6671f08 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet10.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet11.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet11.ogg new file mode 100644 index 0000000..4e321f1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet11.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet12.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet12.ogg new file mode 100644 index 0000000..66906e4 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wet/wet12.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet01.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet01.ogg new file mode 100644 index 0000000..41a5e0b Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet02.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet02.ogg new file mode 100644 index 0000000..bed753d Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet03.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet03.ogg new file mode 100644 index 0000000..fa0e9f9 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet03.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet04.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet04.ogg new file mode 100644 index 0000000..620dfe1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet04.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet05.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet05.ogg new file mode 100644 index 0000000..7402905 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet05.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet06.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet06.ogg new file mode 100644 index 0000000..780867b Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet06.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet07.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet07.ogg new file mode 100644 index 0000000..a77e8b4 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet07.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet08.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet08.ogg new file mode 100644 index 0000000..0d74934 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet08.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet09.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet09.ogg new file mode 100644 index 0000000..4b2eaea Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet09.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet10.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet10.ogg new file mode 100644 index 0000000..69294ce Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet10.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet11.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet11.ogg new file mode 100644 index 0000000..5f9a395 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet11.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet12.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet12.ogg new file mode 100644 index 0000000..09e9074 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet12.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet13.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet13.ogg new file mode 100644 index 0000000..1666efe Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet13.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet14.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet14.ogg new file mode 100644 index 0000000..a480773 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet14.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet15.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet15.ogg new file mode 100644 index 0000000..ef788ac Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet15.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet16.ogg b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet16.ogg new file mode 100644 index 0000000..4154c5a Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/actionsounds/wetimpacts/impactwet16.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/funkytown.ogg b/src/main/resources/assets/needsofnature/sounds/funkytown.ogg new file mode 100644 index 0000000..6562b04 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/funkytown.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/pop.ogg b/src/main/resources/assets/needsofnature/sounds/pop.ogg new file mode 100644 index 0000000..f921c03 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/pop.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/rip01.ogg b/src/main/resources/assets/needsofnature/sounds/rip01.ogg new file mode 100644 index 0000000..05568ae Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/rip01.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/rip02.ogg b/src/main/resources/assets/needsofnature/sounds/rip02.ogg new file mode 100644 index 0000000..c96b902 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/rip02.ogg differ diff --git a/src/main/resources/assets/needsofnature/sounds/rip03.ogg b/src/main/resources/assets/needsofnature/sounds/rip03.ogg new file mode 100644 index 0000000..e26dfb5 Binary files /dev/null and b/src/main/resources/assets/needsofnature/sounds/rip03.ogg differ diff --git a/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_empty.png b/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_empty.png new file mode 100644 index 0000000..d300419 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_empty.png differ diff --git a/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_full.png b/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_full.png new file mode 100644 index 0000000..0b34db9 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/entity/horse_liquid_collector/horse_liquid_collector_full.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/overlays/energy_outline.png b/src/main/resources/assets/needsofnature/textures/gui/overlays/energy_outline.png new file mode 100644 index 0000000..67a882d Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/overlays/energy_outline.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_normal.png b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_normal.png new file mode 100644 index 0000000..afa4e96 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_normal.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_ovulation.png b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_ovulation.png new file mode 100644 index 0000000..bbda29f Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_ovulation.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_risky.png b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_risky.png new file mode 100644 index 0000000..c740c16 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_risky.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_safe.png b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_safe.png new file mode 100644 index 0000000..88e4dc8 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/pregnancy_cycle_safe.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/skin_repair.png b/src/main/resources/assets/needsofnature/textures/gui/skin_repair.png new file mode 100644 index 0000000..b00d3cb Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/skin_repair.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/slots/a.png b/src/main/resources/assets/needsofnature/textures/gui/slots/a.png new file mode 100644 index 0000000..09ecd65 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/slots/a.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/slots/d.png b/src/main/resources/assets/needsofnature/textures/gui/slots/d.png new file mode 100644 index 0000000..c1d5cf7 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/slots/d.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/slots/v.png b/src/main/resources/assets/needsofnature/textures/gui/slots/v.png new file mode 100644 index 0000000..12d28b4 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/slots/v.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/ad_mash.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/ad_mash.png new file mode 100644 index 0000000..2cbce45 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/ad_mash.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_background.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_background.png new file mode 100644 index 0000000..2db6cd5 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_background.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_full.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_full.png new file mode 100644 index 0000000..f09a96f Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/attack_full.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_background.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_background.png new file mode 100644 index 0000000..ba3e9e2 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_background.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_full.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_full.png new file mode 100644 index 0000000..df4f289 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/liquid_full.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_background.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_background.png new file mode 100644 index 0000000..dbb05b3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_background.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_full.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_full.png new file mode 100644 index 0000000..dd35af5 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/hud/progress_full.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy.png new file mode 100644 index 0000000..9507877 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy_relief.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy_relief.png new file mode 100644 index 0000000..6060df3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/energy_relief.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/fertility_increaser.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/fertility_increaser.png new file mode 100644 index 0000000..2b054df Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/fertility_increaser.png differ diff --git a/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/pregnant.png b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/pregnant.png new file mode 100644 index 0000000..f8a5cbe Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/gui/sprites/mob_effect/pregnant.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_augmenter.png b/src/main/resources/assets/needsofnature/textures/item/energy_augmenter.png new file mode 100644 index 0000000..3bfb9d6 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_augmenter.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_diminisher.png b/src/main/resources/assets/needsofnature/textures/item/energy_diminisher.png new file mode 100644 index 0000000..7a63882 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_diminisher.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_potion_overlay.png b/src/main/resources/assets/needsofnature/textures/item/energy_potion_overlay.png new file mode 100644 index 0000000..57c689e Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_potion_overlay.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_relief_potion_overlay.png b/src/main/resources/assets/needsofnature/textures/item/energy_relief_potion_overlay.png new file mode 100644 index 0000000..0310ea4 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_relief_potion_overlay.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_relief_tipped_arrow_head.png b/src/main/resources/assets/needsofnature/textures/item/energy_relief_tipped_arrow_head.png new file mode 100644 index 0000000..fe46074 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_relief_tipped_arrow_head.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_stabilizer.png b/src/main/resources/assets/needsofnature/textures/item/energy_stabilizer.png new file mode 100644 index 0000000..b14a2b3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_stabilizer.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/energy_tipped_arrow_head.png b/src/main/resources/assets/needsofnature/textures/item/energy_tipped_arrow_head.png new file mode 100644 index 0000000..f3df814 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/energy_tipped_arrow_head.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/fertile_nectar_overlay.png b/src/main/resources/assets/needsofnature/textures/item/fertile_nectar_overlay.png new file mode 100644 index 0000000..4fbeed0 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/fertile_nectar_overlay.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/fertility_potion_overlay.png b/src/main/resources/assets/needsofnature/textures/item/fertility_potion_overlay.png new file mode 100644 index 0000000..cd86a8c Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/fertility_potion_overlay.png differ diff --git a/src/main/resources/assets/needsofnature/textures/item/flower_mix.png b/src/main/resources/assets/needsofnature/textures/item/flower_mix.png new file mode 100644 index 0000000..c3c55b1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/item/flower_mix.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/energized1.png b/src/main/resources/assets/needsofnature/textures/mob_effects/energized1.png new file mode 100644 index 0000000..c6dd3b1 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/energized1.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/energized2.png b/src/main/resources/assets/needsofnature/textures/mob_effects/energized2.png new file mode 100644 index 0000000..54613f3 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/energized2.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/energized3.png b/src/main/resources/assets/needsofnature/textures/mob_effects/energized3.png new file mode 100644 index 0000000..7c60dc5 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/energized3.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/filled1.png b/src/main/resources/assets/needsofnature/textures/mob_effects/filled1.png new file mode 100644 index 0000000..70793f6 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/filled1.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/filled2.png b/src/main/resources/assets/needsofnature/textures/mob_effects/filled2.png new file mode 100644 index 0000000..2055c00 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/filled2.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/filled3.png b/src/main/resources/assets/needsofnature/textures/mob_effects/filled3.png new file mode 100644 index 0000000..ebfdf67 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/filled3.png differ diff --git a/src/main/resources/assets/needsofnature/textures/mob_effects/pregnant.png b/src/main/resources/assets/needsofnature/textures/mob_effects/pregnant.png new file mode 100644 index 0000000..f8a5cbe Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/mob_effects/pregnant.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_fall.png b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_fall.png new file mode 100644 index 0000000..cff3b2e Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_fall.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_hang.png b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_hang.png new file mode 100644 index 0000000..766a13b Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_hang.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_land.png b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_land.png new file mode 100644 index 0000000..7724f10 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_land.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_water.png b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_water.png new file mode 100644 index 0000000..aa7c724 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/liquid_drip_water.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/liquid_puddle.png b/src/main/resources/assets/needsofnature/textures/particle/liquid_puddle.png new file mode 100644 index 0000000..affaacd Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/liquid_puddle.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric.png b/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric.png new file mode 100644 index 0000000..02e17f8 Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric_color_mask.png b/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric_color_mask.png new file mode 100644 index 0000000..8d3298b Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/ripped_fabric_color_mask.png differ diff --git a/src/main/resources/assets/needsofnature/textures/particle/smallheart.png b/src/main/resources/assets/needsofnature/textures/particle/smallheart.png new file mode 100644 index 0000000..cf3ebdf Binary files /dev/null and b/src/main/resources/assets/needsofnature/textures/particle/smallheart.png differ diff --git a/src/main/resources/data/animationframework/tags/block/wall_surfaces.json b/src/main/resources/data/animationframework/tags/block/wall_surfaces.json new file mode 100644 index 0000000..ff9176f --- /dev/null +++ b/src/main/resources/data/animationframework/tags/block/wall_surfaces.json @@ -0,0 +1,22 @@ +{ + "replace": false, + "values": [ + "minecraft:glass_pane", + "minecraft:white_stained_glass_pane", + "minecraft:orange_stained_glass_pane", + "minecraft:magenta_stained_glass_pane", + "minecraft:light_blue_stained_glass_pane", + "minecraft:yellow_stained_glass_pane", + "minecraft:lime_stained_glass_pane", + "minecraft:pink_stained_glass_pane", + "minecraft:gray_stained_glass_pane", + "minecraft:light_gray_stained_glass_pane", + "minecraft:cyan_stained_glass_pane", + "minecraft:purple_stained_glass_pane", + "minecraft:blue_stained_glass_pane", + "minecraft:brown_stained_glass_pane", + "minecraft:green_stained_glass_pane", + "minecraft:red_stained_glass_pane", + "minecraft:black_stained_glass_pane" + ] +} diff --git a/src/main/resources/data/needsofnature/advancement/acquire_hardware.json b/src/main/resources/data/needsofnature/advancement/acquire_hardware.json new file mode 100644 index 0000000..294e9ec --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/acquire_hardware.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:carrot" + }, + "title": { + "translate": "advancements.needsofnature.acquire_hardware.title" + }, + "description": { + "translate": "advancements.needsofnature.acquire_hardware.description" + }, + "frame": "task", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/bad_idea_beacon.json b/src/main/resources/data/needsofnature/advancement/bad_idea_beacon.json new file mode 100644 index 0000000..b0a9746 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/bad_idea_beacon.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:beacon" + }, + "title": { + "translate": "advancements.needsofnature.bad_idea_beacon.title" + }, + "description": { + "translate": "advancements.needsofnature.bad_idea_beacon.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/collectors_habit.json b/src/main/resources/data/needsofnature/advancement/collectors_habit.json new file mode 100644 index 0000000..a607147 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/collectors_habit.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:filled_to_the_brim", + "display": { + "icon": { + "id": "minecraft:glass_bottle" + }, + "title": { + "translate": "advancements.needsofnature.collectors_habit.title" + }, + "description": { + "translate": "advancements.needsofnature.collectors_habit.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/emergency_snack.json b/src/main/resources/data/needsofnature/advancement/emergency_snack.json new file mode 100644 index 0000000..63afa54 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/emergency_snack.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:honey_bottle" + }, + "title": { + "translate": "advancements.needsofnature.emergency_snack.title" + }, + "description": { + "translate": "advancements.needsofnature.emergency_snack.description" + }, + "frame": "task", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/favouritism.json b/src/main/resources/data/needsofnature/advancement/favouritism.json new file mode 100644 index 0000000..bac48e0 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/favouritism.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:filled_to_the_brim", + "display": { + "icon": { + "id": "minecraft:potion" + }, + "title": { + "translate": "advancements.needsofnature.favouritism.title" + }, + "description": { + "translate": "advancements.needsofnature.favouritism.description" + }, + "frame": "challenge", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/field_research.json b/src/main/resources/data/needsofnature/advancement/field_research.json new file mode 100644 index 0000000..c963290 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/field_research.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:filled_to_the_brim", + "display": { + "icon": { + "id": "minecraft:writable_book" + }, + "title": { + "translate": "advancements.needsofnature.field_research.title" + }, + "description": { + "translate": "advancements.needsofnature.field_research.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/filled_to_the_brim.json b/src/main/resources/data/needsofnature/advancement/filled_to_the_brim.json new file mode 100644 index 0000000..0f73a2a --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/filled_to_the_brim.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:potion" + }, + "title": { + "translate": "advancements.needsofnature.filled_to_the_brim.title" + }, + "description": { + "translate": "advancements.needsofnature.filled_to_the_brim.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/first_pregnancy.json b/src/main/resources/data/needsofnature/advancement/first_pregnancy.json new file mode 100644 index 0000000..41e246c --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/first_pregnancy.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:egg" + }, + "title": { + "translate": "advancements.needsofnature.first_pregnancy.title" + }, + "description": { + "translate": "advancements.needsofnature.first_pregnancy.description" + }, + "frame": "task", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/group_activity.json b/src/main/resources/data/needsofnature/advancement/group_activity.json new file mode 100644 index 0000000..1106678 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/group_activity.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:lead" + }, + "title": { + "translate": "advancements.needsofnature.group_activity.title" + }, + "description": { + "translate": "advancements.needsofnature.group_activity.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/messed_up.json b/src/main/resources/data/needsofnature/advancement/messed_up.json new file mode 100644 index 0000000..e71ec66 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/messed_up.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:natural_curiosity", + "display": { + "icon": { + "id": "minecraft:sponge" + }, + "title": { + "translate": "advancements.needsofnature.messed_up.title" + }, + "description": { + "translate": "advancements.needsofnature.messed_up.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/mother_nature.json b/src/main/resources/data/needsofnature/advancement/mother_nature.json new file mode 100644 index 0000000..318b631 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/mother_nature.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:first_pregnancy", + "display": { + "icon": { + "id": "minecraft:turtle_egg" + }, + "title": { + "translate": "advancements.needsofnature.mother_nature.title" + }, + "description": { + "translate": "advancements.needsofnature.mother_nature.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/natural_curiosity.json b/src/main/resources/data/needsofnature/advancement/natural_curiosity.json new file mode 100644 index 0000000..71f91bb --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/natural_curiosity.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:root", + "display": { + "icon": { + "id": "minecraft:flower_banner_pattern" + }, + "title": { + "translate": "advancements.needsofnature.natural_curiosity.title" + }, + "description": { + "translate": "advancements.needsofnature.natural_curiosity.description" + }, + "frame": "task", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/productive_pregnancy.json b/src/main/resources/data/needsofnature/advancement/productive_pregnancy.json new file mode 100644 index 0000000..98079bf --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/productive_pregnancy.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:first_pregnancy", + "display": { + "icon": { + "id": "minecraft:hay_block" + }, + "title": { + "translate": "advancements.needsofnature.productive_pregnancy.title" + }, + "description": { + "translate": "advancements.needsofnature.productive_pregnancy.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_augmenter.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_augmenter.json new file mode 100644 index 0000000..954d57d --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_augmenter.json @@ -0,0 +1,43 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_flower_mix": { + "conditions": { + "items": [ + { + "items": "needsofnature:flower_mix" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_crimson_fungus": { + "conditions": { + "items": [ + { + "items": "minecraft:crimson_fungus" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:energy_augmenter" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_flower_mix", + "has_crimson_fungus", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:energy_augmenter" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_diminisher.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_diminisher.json new file mode 100644 index 0000000..0636388 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_diminisher.json @@ -0,0 +1,43 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_flower_mix": { + "conditions": { + "items": [ + { + "items": "needsofnature:flower_mix" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_warped_fungus": { + "conditions": { + "items": [ + { + "items": "minecraft:warped_fungus" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:energy_diminisher" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_flower_mix", + "has_warped_fungus", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:energy_diminisher" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_stabilizer.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_stabilizer.json new file mode 100644 index 0000000..0487ab2 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/energy_stabilizer.json @@ -0,0 +1,43 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_energy_diminisher": { + "conditions": { + "items": [ + { + "items": "needsofnature:energy_diminisher" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_glow_berries": { + "conditions": { + "items": [ + { + "items": "minecraft:glow_berries" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:energy_stabilizer" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_energy_diminisher", + "has_glow_berries", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:energy_stabilizer" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/fertile_nectar.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/fertile_nectar.json new file mode 100644 index 0000000..3cd24d1 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/fertile_nectar.json @@ -0,0 +1,43 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_honey_bottle": { + "conditions": { + "items": [ + { + "items": "minecraft:honey_bottle" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_potion": { + "conditions": { + "items": [ + { + "items": "minecraft:potion" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:fertile_nectar" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_honey_bottle", + "has_potion", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:fertile_nectar" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/flower_mix.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/flower_mix.json new file mode 100644 index 0000000..b96a474 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/flower_mix.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_small_flower": { + "conditions": { + "items": [ + { + "items": "#minecraft:small_flowers" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:flower_mix" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_small_flower", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:flower_mix" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/recipes/misc/horse_liquid_collector.json b/src/main/resources/data/needsofnature/advancement/recipes/misc/horse_liquid_collector.json new file mode 100644 index 0000000..e3effe7 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/recipes/misc/horse_liquid_collector.json @@ -0,0 +1,54 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_wheat": { + "conditions": { + "items": [ + { + "items": "minecraft:wheat" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_leather": { + "conditions": { + "items": [ + { + "items": "minecraft:leather" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_cauldron": { + "conditions": { + "items": [ + { + "items": "minecraft:cauldron" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "needsofnature:horse_liquid_collector" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_wheat", + "has_leather", + "has_cauldron", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "needsofnature:horse_liquid_collector" + ] + } +} diff --git a/src/main/resources/data/needsofnature/advancement/root.json b/src/main/resources/data/needsofnature/advancement/root.json new file mode 100644 index 0000000..54fc94c --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/root.json @@ -0,0 +1,23 @@ +{ + "display": { + "icon": { + "id": "needsofnature:horse_liquid_collector" + }, + "title": { + "translate": "advancements.needsofnature.root.title" + }, + "description": { + "translate": "advancements.needsofnature.root.description" + }, + "frame": "task", + "show_toast": false, + "announce_to_chat": false, + "hidden": false, + "background": "minecraft:block/cherry_planks" + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/stable_diet.json b/src/main/resources/data/needsofnature/advancement/stable_diet.json new file mode 100644 index 0000000..7a48417 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/stable_diet.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:emergency_snack", + "display": { + "icon": { + "id": "minecraft:honey_bottle" + }, + "title": { + "translate": "advancements.needsofnature.stable_diet.title" + }, + "description": { + "translate": "advancements.needsofnature.stable_diet.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/tossed_around.json b/src/main/resources/data/needsofnature/advancement/tossed_around.json new file mode 100644 index 0000000..d741c59 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/tossed_around.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:group_activity", + "display": { + "icon": { + "id": "minecraft:rotten_flesh" + }, + "title": { + "translate": "advancements.needsofnature.tossed_around.title" + }, + "description": { + "translate": "advancements.needsofnature.tossed_around.description" + }, + "frame": "goal", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/advancement/udderly_unfortunate.json b/src/main/resources/data/needsofnature/advancement/udderly_unfortunate.json new file mode 100644 index 0000000..e6b51c6 --- /dev/null +++ b/src/main/resources/data/needsofnature/advancement/udderly_unfortunate.json @@ -0,0 +1,23 @@ +{ + "parent": "needsofnature:first_pregnancy", + "display": { + "icon": { + "id": "minecraft:milk_bucket" + }, + "title": { + "translate": "advancements.needsofnature.udderly_unfortunate.title" + }, + "description": { + "translate": "advancements.needsofnature.udderly_unfortunate.description" + }, + "frame": "task", + "show_toast": true, + "announce_to_chat": true, + "hidden": false + }, + "criteria": { + "done": { + "trigger": "minecraft:impossible" + } + } +} diff --git a/src/main/resources/data/needsofnature/recipe/energy_augmenter.json b/src/main/resources/data/needsofnature/recipe/energy_augmenter.json new file mode 100644 index 0000000..dc33cf7 --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/energy_augmenter.json @@ -0,0 +1,4 @@ +{ + "type": "needsofnature:crafting_energy_augmenter", + "category": "misc" +} diff --git a/src/main/resources/data/needsofnature/recipe/energy_diminisher.json b/src/main/resources/data/needsofnature/recipe/energy_diminisher.json new file mode 100644 index 0000000..d282686 --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/energy_diminisher.json @@ -0,0 +1,4 @@ +{ + "type": "needsofnature:crafting_energy_diminisher", + "category": "misc" +} diff --git a/src/main/resources/data/needsofnature/recipe/energy_stabilizer.json b/src/main/resources/data/needsofnature/recipe/energy_stabilizer.json new file mode 100644 index 0000000..a7c0f23 --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/energy_stabilizer.json @@ -0,0 +1,4 @@ +{ + "type": "needsofnature:crafting_energy_stabilizer", + "category": "misc" +} diff --git a/src/main/resources/data/needsofnature/recipe/fertile_nectar.json b/src/main/resources/data/needsofnature/recipe/fertile_nectar.json new file mode 100644 index 0000000..1c0b5cf --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/fertile_nectar.json @@ -0,0 +1,4 @@ +{ + "type": "needsofnature:crafting_fertile_nectar", + "category": "misc" +} diff --git a/src/main/resources/data/needsofnature/recipe/flower_mix.json b/src/main/resources/data/needsofnature/recipe/flower_mix.json new file mode 100644 index 0000000..3f36db0 --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/flower_mix.json @@ -0,0 +1,13 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "misc", + "ingredients": [ + "#minecraft:small_flowers", + "#minecraft:small_flowers", + "#minecraft:small_flowers" + ], + "result": { + "id": "needsofnature:flower_mix", + "count": 1 + } +} diff --git a/src/main/resources/data/needsofnature/recipe/horse_liquid_collector.json b/src/main/resources/data/needsofnature/recipe/horse_liquid_collector.json new file mode 100644 index 0000000..f1d3458 --- /dev/null +++ b/src/main/resources/data/needsofnature/recipe/horse_liquid_collector.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "WLL", + "LPC", + " S " + ], + "key": { + "W": "minecraft:wheat", + "L": "minecraft:leather", + "P": "#minecraft:planks", + "C": "minecraft:cauldron", + "S": "minecraft:smooth_stone_slab" + }, + "result": { + "id": "needsofnature:horse_liquid_collector" + } +} diff --git a/src/main/resources/data/trinkets/entities/player.json b/src/main/resources/data/trinkets/entities/player.json new file mode 100644 index 0000000..8e51aaa --- /dev/null +++ b/src/main/resources/data/trinkets/entities/player.json @@ -0,0 +1,10 @@ +{ + "entities": [ + "minecraft:player" + ], + "slots": [ + "legs/v", + "legs/a", + "legs/d" + ] +} diff --git a/src/main/resources/data/trinkets/slots/legs/a.json b/src/main/resources/data/trinkets/slots/legs/a.json new file mode 100644 index 0000000..f256f6d --- /dev/null +++ b/src/main/resources/data/trinkets/slots/legs/a.json @@ -0,0 +1,8 @@ +{ + "icon": "needsofnature:gui/slots/a", + "order": 91, + "amount": 1, + "validator_predicates": [ + "trinkets:tag" + ] +} diff --git a/src/main/resources/data/trinkets/slots/legs/d.json b/src/main/resources/data/trinkets/slots/legs/d.json new file mode 100644 index 0000000..e032c4c --- /dev/null +++ b/src/main/resources/data/trinkets/slots/legs/d.json @@ -0,0 +1,9 @@ +{ + "icon": "needsofnature:gui/slots/d", + "order": 92, + "amount": 1, + "validator_predicates": [ + "trinkets:tag", + "needsofnature:male" + ] +} diff --git a/src/main/resources/data/trinkets/slots/legs/v.json b/src/main/resources/data/trinkets/slots/legs/v.json new file mode 100644 index 0000000..0f3ebfb --- /dev/null +++ b/src/main/resources/data/trinkets/slots/legs/v.json @@ -0,0 +1,9 @@ +{ + "icon": "needsofnature:gui/slots/v", + "order": 90, + "amount": 1, + "validator_predicates": [ + "trinkets:tag", + "needsofnature:female" + ] +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..58d6f1b --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "id": "needsofnature", + "version": "1.4.4", + "name": "NeedsOfNature", + "description": "Magic mod using AnimationDirector to play animations.", + "license": "MPL-2.0", + "icon": "assets/needsofnature/icon.png", + "environment": "*", + "entrypoints": { + "main": [ + "com.nonid.NeedsOfNature" + ], + "client": [ + "com.nonid.NeedsOfNatureClient" + ], + "modmenu": [ + "com.nonid.integration.NonModMenuIntegration" + ], + "jade": [ + "com.nonid.integration.jade.NonJadePlugin" + ] + }, + "mixins": [ + "needsofnature.animation.mixins.json", + { + "config": "needsofnature.animation.client.mixins.json", + "environment": "client" + }, + "needsofnature.mixins.json" + ], + "depends": { + "fabricloader": "\u003e\u003d0.18.2", + "minecraft": "~1.21.11", + "java": "\u003e\u003d21", + "fabric-api": "*", + "geckolib": "\u003e\u003d5.4.5" + }, + "breaks": { + "animationframework": "*" + }, + "suggests": { + "jade": "*", + "trinkets": "*", + "quickskin": "*", + "skin_overrides": "*" + } +} \ No newline at end of file diff --git a/src/main/resources/needsofnature.animation.client.mixins.json b/src/main/resources/needsofnature.animation.client.mixins.json new file mode 100644 index 0000000..e69f91d --- /dev/null +++ b/src/main/resources/needsofnature.animation.client.mixins.json @@ -0,0 +1,31 @@ +{ + "required": true, + "package": "com.nonid.internal.animation.mixin.client", + "compatibilityLevel": "JAVA_21", + "client": [ + "EntityAnimationParticlePositionMixin", + "ClientWorldAnimatedPlayerParticleMixin", + "CameraAnimationMixin", + "CameraPositionAccessMixin", + "ClientPlayerMovementMixin", + "ClientPlayerInteractionManagerMixin", + "CuboidGeoBoneHiddenCubeMixin", + "InputAccessor", + "EntityRenderStateMixin", + "EntityRendererAnimationNameTagMixin", + "EntityRenderManagerAccessor", + "EntityRendererInvoker", + "EntityRenderManagerMixin", + "HeldItemRendererMixin", + "KeyboardInputMixin", + "LivingEntityJumpMixin", + "LivingEntityRendererAnimationNameTagMixin", + "LivingEntityRendererMixin", + "MinecraftClientMixin", + "MouseScrollAnimationZoomMixin", + "WorldRendererFirstPersonActorMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/needsofnature.animation.mixins.json b/src/main/resources/needsofnature.animation.mixins.json new file mode 100644 index 0000000..2a61098 --- /dev/null +++ b/src/main/resources/needsofnature.animation.mixins.json @@ -0,0 +1,23 @@ +{ + "required": true, + "package": "com.nonid.internal.animation.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "EntityPushAwayMixin", + "LivingEntityPushableMixin", + "LivingEntityDamageMixin", + "MobEntityIgnoreAttackersMixin", + "ServerPlayNetworkHandlerMixin", + "TargetPredicateMixin" + ], + "client": [ + "client.EntityPushAwayMixin", + "client.LivingEntityPushableMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} diff --git a/src/main/resources/needsofnature.mixins.json b/src/main/resources/needsofnature.mixins.json new file mode 100644 index 0000000..a7f22f8 --- /dev/null +++ b/src/main/resources/needsofnature.mixins.json @@ -0,0 +1,49 @@ +{ + "required": true, + "package": "com.nonid.mixin", + "plugin": "com.nonid.mixin.NonMixinPlugin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "AnimalEntityBreedingMixin", + "AnimalMateGoalAnimationBreedingMixin", + "BrewingRecipeRegistryMixin", + "CreeperEntityNoNAnimationMixin", + "FleeEntityGoalMixin", + "MobEntityGenderMixin", + "MobEntityEnergyMixin", + "MobEntityPregnancyFriendlyTargetMixin", + "EntityGenderPersistenceMixin", + "EntityEnergyPersistenceMixin", + "FileResourcePackProviderAccessor", + "PlayerEntityEnergyMixin", + "PlayerEntityDestroyedSkinMixin", + "PlayerEntityGenderMixin", + "PlayerPregnancyMixin", + "PlayerLiquidMixin", + "PlayerLiquidMovementMixin", + "ProjectileEntityAnimationRescueMixin", + "ResourcePackManagerNoNPackMixin", + "ServerPlayerEntityDamageMixin", + "LivingEntityLiquidJumpMixin", + "LivingEntityLiquidBottleConsumeMixin" + ], + "client": [ + "client.GameRendererReactiveImpactFovMixin", + "client.InGameHudMixin", + "client.StatusEffectsDisplayMixin", + "client.EntityRendererDebugNameMixin", + "client.HandledScreenTrinketSlotStateMixin", + "client.HandledScreenAccessor", + "client.LivingEntityRendererDebugLabelMixin", + "client.PlayerEntityRendererDestroyedSkinMixin", + "client.LivingEntityRendererPlayerTextureMixin", + "client.TranslationStorageStatLabelMixin", + "client.ResourcePackOrganizerHideNoNExternalMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +}