Compare commits
8 Commits
main
...
feature/it
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17998933c | ||
| 3a1082dc38 | |||
| 46e7cf8fe7 | |||
| 59f4064259 | |||
|
|
a71093ba9c | ||
| 73d70e212d | |||
|
|
bb731bd488 | ||
|
|
f6466360b6 |
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Disable autocrlf on generated files, they always generate with LF
|
||||||
|
# Add any extra files or paths here to make git stop saying they
|
||||||
|
# are changed when only line endings change.
|
||||||
|
src/generated/**/.cache/cache text eol=lf
|
||||||
|
src/generated/**/*.json text eol=lf
|
||||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Eclipse
|
||||||
|
bin/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
.metadata/
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
|
||||||
|
# IntelliJ IDEA
|
||||||
|
out/
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
|
||||||
|
# Other
|
||||||
|
eclipse/
|
||||||
|
run/
|
||||||
|
run-client*/
|
||||||
|
run-data/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Forge MDK
|
||||||
|
forge*changelog.txt
|
||||||
|
|
||||||
|
# Dev tools (local config)
|
||||||
|
.claude/
|
||||||
|
.superpowers/
|
||||||
|
.mcp.json
|
||||||
|
.codebase-index-cache.json
|
||||||
|
|
||||||
|
# Node (not used)
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
.prettierrc.yaml
|
||||||
|
|
||||||
|
# Build logs
|
||||||
|
build_output.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
139
LICENSE
Normal file
139
LICENSE
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# TiedUp! Remake - License
|
||||||
|
|
||||||
|
**Effective Date:** January 2025
|
||||||
|
**Applies to:** All versions of TiedUp! Remake (past, present, and future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This software is licensed under **GPL-3.0 with Commons Clause** and additional restrictions.
|
||||||
|
|
||||||
|
**You CAN:**
|
||||||
|
- Use the mod for free
|
||||||
|
- Modify the source code
|
||||||
|
- Distribute the mod (with source code)
|
||||||
|
- Create derivative works (must be open source under the same license)
|
||||||
|
|
||||||
|
**You CANNOT:**
|
||||||
|
- Sell this software
|
||||||
|
- Put this software behind a paywall, subscription, or any form of monetization
|
||||||
|
- Distribute without providing source code
|
||||||
|
- Use a more restrictive license for derivative works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full License Terms
|
||||||
|
|
||||||
|
### Part 1: Commons Clause Restriction
|
||||||
|
|
||||||
|
"Commons Clause" License Condition v1.0
|
||||||
|
|
||||||
|
The Software is provided to you by the Licensor under the License, as defined
|
||||||
|
below, subject to the following condition.
|
||||||
|
|
||||||
|
Without limiting other conditions in the License, the grant of rights under the
|
||||||
|
License will not include, and the License does not grant to you, the right to
|
||||||
|
Sell the Software.
|
||||||
|
|
||||||
|
For purposes of the foregoing, "Sell" means practicing any or all of the rights
|
||||||
|
granted to you under the License to provide to third parties, for a fee or other
|
||||||
|
consideration (including without limitation fees for hosting or consulting/
|
||||||
|
support services related to the Software), a product or service whose value
|
||||||
|
derives, entirely or substantially, from the functionality of the Software.
|
||||||
|
|
||||||
|
**Additional Monetization Restrictions:**
|
||||||
|
|
||||||
|
The following are explicitly prohibited:
|
||||||
|
1. Selling the Software or any derivative work
|
||||||
|
2. Requiring payment, subscription, or donation to access or download the Software
|
||||||
|
3. Placing the Software behind a paywall of any kind (Patreon, Ko-fi, etc.)
|
||||||
|
4. Bundling the Software with paid products or services
|
||||||
|
5. Using the Software as an incentive for paid memberships or subscriptions
|
||||||
|
6. Early access monetization (charging for early access to updates)
|
||||||
|
|
||||||
|
**Permitted:**
|
||||||
|
- Accepting voluntary donations (as long as the Software remains freely accessible)
|
||||||
|
- Using the Software on monetized content platforms (YouTube, Twitch, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part 2: GNU General Public License v3.0
|
||||||
|
|
||||||
|
Copyright (C) 2024-2025 TiedUp! Remake Contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The full text of GPL-3.0 is available at:
|
||||||
|
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part 3: Asset Exclusions
|
||||||
|
|
||||||
|
The following assets are NOT covered by this license and remain property of
|
||||||
|
their respective owners:
|
||||||
|
|
||||||
|
1. **Original kdnp mod Assets** (textures, models, sounds from the 1.12.2 version)
|
||||||
|
- Original creators: Yuti & Marl Velius
|
||||||
|
- These assets are used under fair use for preservation/educational purposes
|
||||||
|
- Contact original authors for commercial use
|
||||||
|
|
||||||
|
2. **Minecraft Assets**
|
||||||
|
- All Minecraft textures, sounds, and code belong to Mojang Studios/Microsoft
|
||||||
|
- Subject to Minecraft EULA: https://www.minecraft.net/en-us/eula
|
||||||
|
|
||||||
|
3. **Third-Party Libraries**
|
||||||
|
- PlayerAnimator: Subject to its own license (dev.kosmx.player-anim)
|
||||||
|
- Forge: Subject to Forge license (MinecraftForge)
|
||||||
|
- Other dependencies: Subject to their respective licenses
|
||||||
|
|
||||||
|
**Code written for this remake** (files in `src/main/java/com/tiedup/remake/`)
|
||||||
|
is fully covered by this GPL-3.0 + Commons Clause license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part 4: Derivative Works
|
||||||
|
|
||||||
|
Any derivative work based on this Software MUST:
|
||||||
|
|
||||||
|
1. Be distributed under this same license (GPL-3.0 + Commons Clause)
|
||||||
|
2. Provide complete source code
|
||||||
|
3. Maintain all copyright notices
|
||||||
|
4. Not be sold or monetized in any way
|
||||||
|
5. Credit the original TiedUp! Remake project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part 5: Disclaimer
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPDX Identifier
|
||||||
|
|
||||||
|
```
|
||||||
|
SPDX-License-Identifier: GPL-3.0-only WITH Commons-Clause-1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For licensing questions or permission requests, open an issue on the project repository.
|
||||||
523
LICENSE.txt
Normal file
523
LICENSE.txt
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
Unless noted below, Minecraft Forge, Forge Mod Loader, and all
|
||||||
|
parts herein are licensed under the terms of the LGPL 2.1 found
|
||||||
|
here http://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt and
|
||||||
|
copied below.
|
||||||
|
|
||||||
|
Homepage: http://minecraftforge.net/
|
||||||
|
https://github.com/MinecraftForge/MinecraftForge
|
||||||
|
|
||||||
|
|
||||||
|
A note on authorship:
|
||||||
|
All source artifacts are property of their original author, with
|
||||||
|
the exclusion of the contents of the patches directory and others
|
||||||
|
copied from it from time to time. Authorship of the contents of
|
||||||
|
the patches directory is retained by the Minecraft Forge project.
|
||||||
|
This is because the patches are partially machine generated
|
||||||
|
artifacts, and are changed heavily due to the way forge works.
|
||||||
|
Individual attribution within them is impossible.
|
||||||
|
|
||||||
|
Consent:
|
||||||
|
All contributions to Forge must consent to the release of any
|
||||||
|
patch content to the Forge project.
|
||||||
|
|
||||||
|
A note on infectivity:
|
||||||
|
The LGPL is chosen specifically so that projects may depend on Forge
|
||||||
|
features without being infected with its license. That is the
|
||||||
|
purpose of the LGPL. Mods and others using this code via ordinary
|
||||||
|
Java mechanics for referencing libraries are specifically not bound
|
||||||
|
by Forge's license for the Mod code.
|
||||||
|
|
||||||
|
|
||||||
|
=== MCP Data ===
|
||||||
|
This software includes data from the Minecraft Coder Pack (MCP), with kind permission
|
||||||
|
from them. The license to MCP data is not transitive - distribution of this data by
|
||||||
|
third parties requires independent licensing from the MCP team. This data is not
|
||||||
|
redistributable without permission from the MCP team.
|
||||||
|
|
||||||
|
=== Sharing ===
|
||||||
|
I grant permission for some parts of FML to be redistributed outside the terms of the LGPL, for the benefit of
|
||||||
|
the minecraft modding community. All contributions to these parts should be licensed under the same additional grant.
|
||||||
|
|
||||||
|
-- Runtime patcher --
|
||||||
|
License is granted to redistribute the runtime patcher code (src/main/java/net/minecraftforge/fml/common/patcher
|
||||||
|
and subdirectories) under any alternative open source license as classified by the OSI (http://opensource.org/licenses)
|
||||||
|
|
||||||
|
-- ASM transformers --
|
||||||
|
License is granted to redistribute the ASM transformer code (src/main/java/net/minecraftforge/common/asm/ and subdirectories)
|
||||||
|
under any alternative open source license as classified by the OSI (http://opensource.org/licenses)
|
||||||
|
|
||||||
|
=========================================================================
|
||||||
|
This software includes portions from the Apache Maven project at
|
||||||
|
http://maven.apache.org/ specifically the ComparableVersion.java code. It is
|
||||||
|
included based on guidelines at
|
||||||
|
http://www.softwarefreedom.org/resources/2007/gpl-non-gpl-collaboration.html
|
||||||
|
with notices intact. The only change is a non-functional change of package name.
|
||||||
|
|
||||||
|
This software contains a partial repackaging of javaxdelta, a BSD licensed program for generating
|
||||||
|
binary differences and applying them, sourced from the subversion at http://sourceforge.net/projects/javaxdelta/
|
||||||
|
authored by genman, heikok, pivot.
|
||||||
|
The only changes are to replace some Trove collection types with standard Java collections, and repackaged.
|
||||||
|
|
||||||
|
This software includes the Monocraft font from https://github.com/IdreesInc/Monocraft/ for use in the early loading
|
||||||
|
display.
|
||||||
|
=========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 2.1, February 1999
|
||||||
|
|
||||||
|
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
[This is the first released version of the Lesser GPL. It also counts
|
||||||
|
as the successor of the GNU Library Public License, version 2, hence
|
||||||
|
the version number 2.1.]
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
Licenses are intended to guarantee your freedom to share and change
|
||||||
|
free software--to make sure the software is free for all its users.
|
||||||
|
|
||||||
|
This license, the Lesser General Public License, applies to some
|
||||||
|
specially designated software packages--typically libraries--of the
|
||||||
|
Free Software Foundation and other authors who decide to use it. You
|
||||||
|
can use it too, but we suggest you first think carefully about whether
|
||||||
|
this license or the ordinary General Public License is the better
|
||||||
|
strategy to use in any particular case, based on the explanations below.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom of use,
|
||||||
|
not price. Our General Public Licenses are designed to make sure that
|
||||||
|
you have the freedom to distribute copies of free software (and charge
|
||||||
|
for this service if you wish); that you receive source code or can get
|
||||||
|
it if you want it; that you can change the software and use pieces of
|
||||||
|
it in new free programs; and that you are informed that you can do
|
||||||
|
these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
distributors to deny you these rights or to ask you to surrender these
|
||||||
|
rights. These restrictions translate to certain responsibilities for
|
||||||
|
you if you distribute copies of the library or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of the library, whether gratis
|
||||||
|
or for a fee, you must give the recipients all the rights that we gave
|
||||||
|
you. You must make sure that they, too, receive or can get the source
|
||||||
|
code. If you link other code with the library, you must provide
|
||||||
|
complete object files to the recipients, so that they can relink them
|
||||||
|
with the library after making changes to the library and recompiling
|
||||||
|
it. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
We protect your rights with a two-step method: (1) we copyright the
|
||||||
|
library, and (2) we offer you this license, which gives you legal
|
||||||
|
permission to copy, distribute and/or modify the library.
|
||||||
|
|
||||||
|
To protect each distributor, we want to make it very clear that
|
||||||
|
there is no warranty for the free library. Also, if the library is
|
||||||
|
modified by someone else and passed on, the recipients should know
|
||||||
|
that what they have is not the original version, so that the original
|
||||||
|
author's reputation will not be affected by problems that might be
|
||||||
|
introduced by others.
|
||||||
|
|
||||||
|
Finally, software patents pose a constant threat to the existence of
|
||||||
|
any free program. We wish to make sure that a company cannot
|
||||||
|
effectively restrict the users of a free program by obtaining a
|
||||||
|
restrictive license from a patent holder. Therefore, we insist that
|
||||||
|
any patent license obtained for a version of the library must be
|
||||||
|
consistent with the full freedom of use specified in this license.
|
||||||
|
|
||||||
|
Most GNU software, including some libraries, is covered by the
|
||||||
|
ordinary GNU General Public License. This license, the GNU Lesser
|
||||||
|
General Public License, applies to certain designated libraries, and
|
||||||
|
is quite different from the ordinary General Public License. We use
|
||||||
|
this license for certain libraries in order to permit linking those
|
||||||
|
libraries into non-free programs.
|
||||||
|
|
||||||
|
When a program is linked with a library, whether statically or using
|
||||||
|
a shared library, the combination of the two is legally speaking a
|
||||||
|
combined work, a derivative of the original library. The ordinary
|
||||||
|
General Public License therefore permits such linking only if the
|
||||||
|
entire combination fits its criteria of freedom. The Lesser General
|
||||||
|
Public License permits more lax criteria for linking other code with
|
||||||
|
the library.
|
||||||
|
|
||||||
|
We call this license the "Lesser" General Public License because it
|
||||||
|
does Less to protect the user's freedom than the ordinary General
|
||||||
|
Public License. It also provides other free software developers Less
|
||||||
|
of an advantage over competing non-free programs. These disadvantages
|
||||||
|
are the reason we use the ordinary General Public License for many
|
||||||
|
libraries. However, the Lesser license provides advantages in certain
|
||||||
|
special circumstances.
|
||||||
|
|
||||||
|
For example, on rare occasions, there may be a special need to
|
||||||
|
encourage the widest possible use of a certain library, so that it becomes
|
||||||
|
a de-facto standard. To achieve this, non-free programs must be
|
||||||
|
allowed to use the library. A more frequent case is that a free
|
||||||
|
library does the same job as widely used non-free libraries. In this
|
||||||
|
case, there is little to gain by limiting the free library to free
|
||||||
|
software only, so we use the Lesser General Public License.
|
||||||
|
|
||||||
|
In other cases, permission to use a particular library in non-free
|
||||||
|
programs enables a greater number of people to use a large body of
|
||||||
|
free software. For example, permission to use the GNU C Library in
|
||||||
|
non-free programs enables many more people to use the whole GNU
|
||||||
|
operating system, as well as its variant, the GNU/Linux operating
|
||||||
|
system.
|
||||||
|
|
||||||
|
Although the Lesser General Public License is Less protective of the
|
||||||
|
users' freedom, it does ensure that the user of a program that is
|
||||||
|
linked with the Library has the freedom and the wherewithal to run
|
||||||
|
that program using a modified version of the Library.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow. Pay close attention to the difference between a
|
||||||
|
"work based on the library" and a "work that uses the library". The
|
||||||
|
former contains code derived from the library, whereas the latter must
|
||||||
|
be combined with the library in order to run.
|
||||||
|
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License Agreement applies to any software library or other
|
||||||
|
program which contains a notice placed by the copyright holder or
|
||||||
|
other authorized party saying it may be distributed under the terms of
|
||||||
|
this Lesser General Public License (also called "this License").
|
||||||
|
Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
A "library" means a collection of software functions and/or data
|
||||||
|
prepared so as to be conveniently linked with application programs
|
||||||
|
(which use some of those functions and data) to form executables.
|
||||||
|
|
||||||
|
The "Library", below, refers to any such software library or work
|
||||||
|
which has been distributed under these terms. A "work based on the
|
||||||
|
Library" means either the Library or any derivative work under
|
||||||
|
copyright law: that is to say, a work containing the Library or a
|
||||||
|
portion of it, either verbatim or with modifications and/or translated
|
||||||
|
straightforwardly into another language. (Hereinafter, translation is
|
||||||
|
included without limitation in the term "modification".)
|
||||||
|
|
||||||
|
"Source code" for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For a library, complete source code means
|
||||||
|
all the source code for all modules it contains, plus any associated
|
||||||
|
interface definition files, plus the scripts used to control compilation
|
||||||
|
and installation of the library.
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running a program using the Library is not restricted, and output from
|
||||||
|
such a program is covered only if its contents constitute a work based
|
||||||
|
on the Library (independent of the use of the Library in a tool for
|
||||||
|
writing it). Whether that is true depends on what the Library does
|
||||||
|
and what the program that uses the Library does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Library's
|
||||||
|
complete source code as you receive it, in any medium, provided that
|
||||||
|
you conspicuously and appropriately publish on each copy an
|
||||||
|
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||||
|
all the notices that refer to this License and to the absence of any
|
||||||
|
warranty; and distribute a copy of this License along with the
|
||||||
|
Library.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy,
|
||||||
|
and you may at your option offer warranty protection in exchange for a
|
||||||
|
fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Library or any portion
|
||||||
|
of it, thus forming a work based on the Library, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The modified work must itself be a software library.
|
||||||
|
|
||||||
|
b) You must cause the files modified to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
c) You must cause the whole of the work to be licensed at no
|
||||||
|
charge to all third parties under the terms of this License.
|
||||||
|
|
||||||
|
d) If a facility in the modified Library refers to a function or a
|
||||||
|
table of data to be supplied by an application program that uses
|
||||||
|
the facility, other than as an argument passed when the facility
|
||||||
|
is invoked, then you must make a good faith effort to ensure that,
|
||||||
|
in the event an application does not supply such function or
|
||||||
|
table, the facility still operates, and performs whatever part of
|
||||||
|
its purpose remains meaningful.
|
||||||
|
|
||||||
|
(For example, a function in a library to compute square roots has
|
||||||
|
a purpose that is entirely well-defined independent of the
|
||||||
|
application. Therefore, Subsection 2d requires that any
|
||||||
|
application-supplied function or table used by this function must
|
||||||
|
be optional: if the application does not supply it, the square
|
||||||
|
root function must still compute square roots.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Library,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Library, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote
|
||||||
|
it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Library.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Library
|
||||||
|
with the Library (or with a work based on the Library) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||||
|
License instead of this License to a given copy of the Library. To do
|
||||||
|
this, you must alter all the notices that refer to this License, so
|
||||||
|
that they refer to the ordinary GNU General Public License, version 2,
|
||||||
|
instead of to this License. (If a newer version than version 2 of the
|
||||||
|
ordinary GNU General Public License has appeared, then you can specify
|
||||||
|
that version instead if you wish.) Do not make any other change in
|
||||||
|
these notices.
|
||||||
|
|
||||||
|
Once this change is made in a given copy, it is irreversible for
|
||||||
|
that copy, so the ordinary GNU General Public License applies to all
|
||||||
|
subsequent copies and derivative works made from that copy.
|
||||||
|
|
||||||
|
This option is useful when you wish to copy part of the code of
|
||||||
|
the Library into a program that is not a library.
|
||||||
|
|
||||||
|
4. You may copy and distribute the Library (or a portion or
|
||||||
|
derivative of it, under Section 2) in object code or executable form
|
||||||
|
under the terms of Sections 1 and 2 above provided that you accompany
|
||||||
|
it with the complete corresponding machine-readable source code, which
|
||||||
|
must be distributed under the terms of Sections 1 and 2 above on a
|
||||||
|
medium customarily used for software interchange.
|
||||||
|
|
||||||
|
If distribution of object code is made by offering access to copy
|
||||||
|
from a designated place, then offering equivalent access to copy the
|
||||||
|
source code from the same place satisfies the requirement to
|
||||||
|
distribute the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
5. A program that contains no derivative of any portion of the
|
||||||
|
Library, but is designed to work with the Library by being compiled or
|
||||||
|
linked with it, is called a "work that uses the Library". Such a
|
||||||
|
work, in isolation, is not a derivative work of the Library, and
|
||||||
|
therefore falls outside the scope of this License.
|
||||||
|
|
||||||
|
However, linking a "work that uses the Library" with the Library
|
||||||
|
creates an executable that is a derivative of the Library (because it
|
||||||
|
contains portions of the Library), rather than a "work that uses the
|
||||||
|
library". The executable is therefore covered by this License.
|
||||||
|
Section 6 states terms for distribution of such executables.
|
||||||
|
|
||||||
|
When a "work that uses the Library" uses material from a header file
|
||||||
|
that is part of the Library, the object code for the work may be a
|
||||||
|
derivative work of the Library even though the source code is not.
|
||||||
|
Whether this is true is especially significant if the work can be
|
||||||
|
linked without the Library, or if the work is itself a library. The
|
||||||
|
threshold for this to be true is not precisely defined by law.
|
||||||
|
|
||||||
|
If such an object file uses only numerical parameters, data
|
||||||
|
structure layouts and accessors, and small macros and small inline
|
||||||
|
functions (ten lines or less in length), then the use of the object
|
||||||
|
file is unrestricted, regardless of whether it is legally a derivative
|
||||||
|
work. (Executables containing this object code plus portions of the
|
||||||
|
Library will still fall under Section 6.)
|
||||||
|
|
||||||
|
Otherwise, if the work is a derivative of the Library, you may
|
||||||
|
distribute the object code for the work under the terms of Section 6.
|
||||||
|
Any executables containing that work also fall under Section 6,
|
||||||
|
whether or not they are linked directly with the Library itself.
|
||||||
|
|
||||||
|
6. As an exception to the Sections above, you may also combine or
|
||||||
|
link a "work that uses the Library" with the Library to produce a
|
||||||
|
work containing portions of the Library, and distribute that work
|
||||||
|
under terms of your choice, provided that the terms permit
|
||||||
|
modification of the work for the customer's own use and reverse
|
||||||
|
engineering for debugging such modifications.
|
||||||
|
|
||||||
|
You must give prominent notice with each copy of the work that the
|
||||||
|
Library is used in it and that the Library and its use are covered by
|
||||||
|
this License. You must supply a copy of this License. If the work
|
||||||
|
during execution displays copyright notices, you must include the
|
||||||
|
copyright notice for the Library among them, as well as a reference
|
||||||
|
directing the user to the copy of this License. Also, you must do one
|
||||||
|
of these things:
|
||||||
|
|
||||||
|
a) Accompany the work with the complete corresponding
|
||||||
|
machine-readable source code for the Library including whatever
|
||||||
|
changes were used in the work (which must be distributed under
|
||||||
|
Sections 1 and 2 above); and, if the work is an executable linked
|
||||||
|
with the Library, with the complete machine-readable "work that
|
||||||
|
uses the Library", as object code and/or source code, so that the
|
||||||
|
user can modify the Library and then relink to produce a modified
|
||||||
|
executable containing the modified Library. (It is understood
|
||||||
|
that the user who changes the contents of definitions files in the
|
||||||
|
Library will not necessarily be able to recompile the application
|
||||||
|
to use the modified definitions.)
|
||||||
|
|
||||||
|
b) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (1) uses at run time a
|
||||||
|
copy of the library already present on the user's computer system,
|
||||||
|
rather than copying library functions into the executable, and (2)
|
||||||
|
will operate properly with a modified version of the library, if
|
||||||
|
the user installs one, as long as the modified version is
|
||||||
|
interface-compatible with the version that the work was made with.
|
||||||
|
|
||||||
|
c) Accompany the work with a written offer, valid for at
|
||||||
|
least three years, to give the same user the materials
|
||||||
|
specified in Subsection 6a, above, for a charge no more
|
||||||
|
than the cost of performing this distribution.
|
||||||
|
|
||||||
|
d) If distribution of the work is made by offering access to copy
|
||||||
|
from a designated place, offer equivalent access to copy the above
|
||||||
|
specified materials from the same place.
|
||||||
|
|
||||||
|
e) Verify that the user has already received a copy of these
|
||||||
|
materials or that you have already sent this user a copy.
|
||||||
|
|
||||||
|
For an executable, the required form of the "work that uses the
|
||||||
|
Library" must include any data and utility programs needed for
|
||||||
|
reproducing the executable from it. However, as a special exception,
|
||||||
|
the materials to be distributed need not include anything that is
|
||||||
|
normally distributed (in either source or binary form) with the major
|
||||||
|
components (compiler, kernel, and so on) of the operating system on
|
||||||
|
which the executable runs, unless that component itself accompanies
|
||||||
|
the executable.
|
||||||
|
|
||||||
|
It may happen that this requirement contradicts the license
|
||||||
|
restrictions of other proprietary libraries that do not normally
|
||||||
|
accompany the operating system. Such a contradiction means you cannot
|
||||||
|
use both them and the Library together in an executable that you
|
||||||
|
distribute.
|
||||||
|
|
||||||
|
7. You may place library facilities that are a work based on the
|
||||||
|
Library side-by-side in a single library together with other library
|
||||||
|
facilities not covered by this License, and distribute such a combined
|
||||||
|
library, provided that the separate distribution of the work based on
|
||||||
|
the Library and of the other library facilities is otherwise
|
||||||
|
permitted, and provided that you do these two things:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work
|
||||||
|
based on the Library, uncombined with any other library
|
||||||
|
facilities. This must be distributed under the terms of the
|
||||||
|
Sections above.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library of the fact
|
||||||
|
that part of it is a work based on the Library, and explaining
|
||||||
|
where to find the accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
8. You may not copy, modify, sublicense, link with, or distribute
|
||||||
|
the Library except as expressly provided under this License. Any
|
||||||
|
attempt otherwise to copy, modify, sublicense, link with, or
|
||||||
|
distribute the Library is void, and will automatically terminate your
|
||||||
|
rights under this License. However, parties who have received copies,
|
||||||
|
or rights, from you under this License will not have their licenses
|
||||||
|
terminated so long as such parties remain in full compliance.
|
||||||
|
|
||||||
|
9. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Library or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Library (or any work based on the
|
||||||
|
Library), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Library or works based on it.
|
||||||
|
|
||||||
|
10. Each time you redistribute the Library (or any work based on the
|
||||||
|
Library), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute, link with or modify the Library
|
||||||
|
subject to these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties with
|
||||||
|
this License.
|
||||||
|
|
||||||
|
11. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Library at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Library by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Library.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under any
|
||||||
|
particular circumstance, the balance of the section is intended to apply,
|
||||||
|
and the section as a whole is intended to apply in other circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
12. If the distribution and/or use of the Library is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Library under this License may add
|
||||||
|
an explicit geographical distribution limitation excluding those countries,
|
||||||
|
so that distribution is permitted only in or among countries not thus
|
||||||
|
excluded. In such case, this License incorporates the limitation as if
|
||||||
|
written in the body of this License.
|
||||||
|
|
||||||
|
13. The Free Software Foundation may publish revised and/or new
|
||||||
|
versions of the Lesser General Public License from time to time.
|
||||||
|
Such new versions will be similar in spirit to the present version,
|
||||||
|
but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Library
|
||||||
|
specifies a version number of this License which applies to it and
|
||||||
|
"any later version", you have the option of following the terms and
|
||||||
|
conditions either of that version or of any later version published by
|
||||||
|
the Free Software Foundation. If the Library does not specify a
|
||||||
|
license version number, you may choose any version ever published by
|
||||||
|
the Free Software Foundation.
|
||||||
|
|
||||||
|
14. If you wish to incorporate parts of the Library into other free
|
||||||
|
programs whose distribution conditions are incompatible with these,
|
||||||
|
write to the author to ask for permission. For software which is
|
||||||
|
copyrighted by the Free Software Foundation, write to the Free
|
||||||
|
Software Foundation; we sometimes make exceptions for this. Our
|
||||||
|
decision will be guided by the two goals of preserving the free status
|
||||||
|
of all derivatives of our free software and of promoting the sharing
|
||||||
|
and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||||
|
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||||
|
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||||
|
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||||
|
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||||
|
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||||
|
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||||
|
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||||
|
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||||
|
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||||
|
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||||
|
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||||
|
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||||
|
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||||
|
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||||
|
DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
181
Makefile
Normal file
181
Makefile
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# TiedUp! 1.20.1 - Makefile
|
||||||
|
# Simplifies build commands using Java 17
|
||||||
|
|
||||||
|
# Java 17 is required for Forge 1.20.1
|
||||||
|
JAVA_HOME := /usr/lib/jvm/java-17-openjdk
|
||||||
|
export JAVA_HOME
|
||||||
|
|
||||||
|
# Gradle wrapper
|
||||||
|
GRADLE := ./gradlew
|
||||||
|
|
||||||
|
# Mod info (keep in sync with gradle.properties)
|
||||||
|
MOD_NAME := TiedUp
|
||||||
|
MOD_VERSION := 0.5.6-ALPHA
|
||||||
|
JAR_FILE := build/libs/tiedup-$(MOD_VERSION).jar
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
COLOR_RESET := \033[0m
|
||||||
|
COLOR_GREEN := \033[32m
|
||||||
|
COLOR_YELLOW := \033[33m
|
||||||
|
COLOR_BLUE := \033[34m
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
##@ General
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Display this help message
|
||||||
|
@echo ""
|
||||||
|
@echo "$(COLOR_BLUE)TiedUp! 1.20.1 - Makefile Commands$(COLOR_RESET)"
|
||||||
|
@echo ""
|
||||||
|
@awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make $(COLOR_YELLOW)<target>$(COLOR_RESET)\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " $(COLOR_YELLOW)%-15s$(COLOR_RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(COLOR_GREEN)%s$(COLOR_RESET)\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
##@ Building
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build the mod
|
||||||
|
@echo "$(COLOR_GREEN)Building $(MOD_NAME) with Java 17...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) build
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
@echo "$(COLOR_YELLOW)Cleaning build files...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) clean
|
||||||
|
|
||||||
|
.PHONY: rebuild
|
||||||
|
rebuild: clean build ## Clean and rebuild
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
|
release: clean ## Build for release (clean + build)
|
||||||
|
@echo "$(COLOR_GREEN)Building release version of $(MOD_NAME)...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) build
|
||||||
|
@mkdir -p build/release
|
||||||
|
@cp build/reobfJar/output.jar build/release/tiedup-$(MOD_VERSION).jar
|
||||||
|
@echo "$(COLOR_GREEN)Release build complete!$(COLOR_RESET)"
|
||||||
|
@ls -lh build/release/
|
||||||
|
|
||||||
|
##@ Running
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run: ## Run Minecraft client
|
||||||
|
@echo "$(COLOR_GREEN)Launching Minecraft client...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) runClient
|
||||||
|
|
||||||
|
.PHONY: server
|
||||||
|
server: ## Run dedicated server
|
||||||
|
@echo "$(COLOR_GREEN)Launching dedicated server...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) runServer
|
||||||
|
|
||||||
|
.PHONY: client2
|
||||||
|
client2: ## Run second client instance
|
||||||
|
@echo "$(COLOR_GREEN)Launching client 2...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) runClient2
|
||||||
|
|
||||||
|
.PHONY: client3
|
||||||
|
client3: ## Run third client instance
|
||||||
|
@echo "$(COLOR_GREEN)Launching client 3...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) runClient3
|
||||||
|
|
||||||
|
.PHONY: data
|
||||||
|
data: ## Run data generators
|
||||||
|
@echo "$(COLOR_GREEN)Running data generators...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) runData
|
||||||
|
|
||||||
|
##@ Multiplayer Testing
|
||||||
|
|
||||||
|
.PHONY: mptest
|
||||||
|
mptest: mp-kill build _mp-setup ## Build and launch server + 2 clients
|
||||||
|
@echo "$(COLOR_GREEN)Starting multiplayer test environment...$(COLOR_RESET)"
|
||||||
|
@echo "$(COLOR_BLUE)Launching 3 terminals: Server, Dev1, Dev2$(COLOR_RESET)"
|
||||||
|
@konsole --new-tab -e bash -c "cd '$(CURDIR)' && $(GRADLE) runServer; read -p 'Press enter to close'" &
|
||||||
|
@sleep 2
|
||||||
|
@konsole --new-tab -e bash -c "sleep 12 && cd '$(CURDIR)' && $(GRADLE) runClient; read -p 'Press enter to close'" &
|
||||||
|
@konsole --new-tab -e bash -c "sleep 17 && cd '$(CURDIR)' && $(GRADLE) runClient2; read -p 'Press enter to close'" &
|
||||||
|
@echo "$(COLOR_GREEN)Terminals launched. Clients will auto-connect after server starts.$(COLOR_RESET)"
|
||||||
|
|
||||||
|
.PHONY: _mp-setup
|
||||||
|
_mp-setup:
|
||||||
|
@mkdir -p run run-client2 run-client3
|
||||||
|
@echo "eula=true" > run/eula.txt
|
||||||
|
@if [ ! -f run/server.properties ]; then \
|
||||||
|
echo "# TiedUp Dev Server" > run/server.properties; \
|
||||||
|
echo "online-mode=false" >> run/server.properties; \
|
||||||
|
echo "enable-command-block=true" >> run/server.properties; \
|
||||||
|
echo "spawn-protection=0" >> run/server.properties; \
|
||||||
|
echo "gamemode=creative" >> run/server.properties; \
|
||||||
|
echo "allow-flight=true" >> run/server.properties; \
|
||||||
|
else \
|
||||||
|
grep -q "online-mode=false" run/server.properties || \
|
||||||
|
sed -i 's/online-mode=true/online-mode=false/' run/server.properties; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: mp-kill
|
||||||
|
mp-kill: ## Kill all Minecraft processes and clean locks
|
||||||
|
@echo "$(COLOR_YELLOW)Killing all Minecraft/Java processes...$(COLOR_RESET)"
|
||||||
|
@-fuser -k 25565/tcp 2>/dev/null; true
|
||||||
|
@-pkill -9 -f "net.minecraft.server.Main" 2>/dev/null; true
|
||||||
|
@-pkill -9 -f "net.minecraft.client.main.Main" 2>/dev/null; true
|
||||||
|
@-pkill -9 -f "MinecraftServer" 2>/dev/null; true
|
||||||
|
@-pkill -9 -f "cpw.mods.bootstraplauncher" 2>/dev/null; true
|
||||||
|
@sleep 1
|
||||||
|
@-rm -f run/world/session.lock run/*/session.lock 2>/dev/null; true
|
||||||
|
@-rm -f run-client2/*/session.lock run-client3/*/session.lock 2>/dev/null; true
|
||||||
|
@echo "$(COLOR_GREEN)Done$(COLOR_RESET)"
|
||||||
|
|
||||||
|
##@ Development
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run tests
|
||||||
|
@$(GRADLE) test
|
||||||
|
|
||||||
|
.PHONY: idea
|
||||||
|
idea: ## Generate IntelliJ IDEA run configurations
|
||||||
|
@$(GRADLE) genIntellijRuns
|
||||||
|
|
||||||
|
.PHONY: eclipse
|
||||||
|
eclipse: ## Generate Eclipse project files
|
||||||
|
@$(GRADLE) eclipse
|
||||||
|
|
||||||
|
##@ Formatting
|
||||||
|
|
||||||
|
.PHONY: format
|
||||||
|
format: ## Format code using Prettier
|
||||||
|
@npx --yes prettier --plugin prettier-plugin-java --tab-width 4 --write "src/**/*.java"
|
||||||
|
|
||||||
|
.PHONY: check-format
|
||||||
|
check-format: ## Check code formatting
|
||||||
|
@npx --yes prettier --plugin prettier-plugin-java --tab-width 4 --check "src/**/*.java"
|
||||||
|
|
||||||
|
##@ Information
|
||||||
|
|
||||||
|
.PHONY: info
|
||||||
|
info: ## Show project information
|
||||||
|
@echo ""
|
||||||
|
@echo "$(COLOR_BLUE) TiedUp! - Project Information$(COLOR_RESET)"
|
||||||
|
@echo "$(COLOR_GREEN)Mod:$(COLOR_RESET) $(MOD_NAME) $(MOD_VERSION)"
|
||||||
|
@echo "$(COLOR_GREEN)Minecraft:$(COLOR_RESET) 1.20.1"
|
||||||
|
@echo "$(COLOR_GREEN)Forge:$(COLOR_RESET) 47.4.10"
|
||||||
|
@echo "$(COLOR_GREEN)Java:$(COLOR_RESET) 17 ($(JAVA_HOME))"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
.PHONY: version
|
||||||
|
version: ## Show Java and Gradle versions
|
||||||
|
@$(JAVA_HOME)/bin/java -version
|
||||||
|
@$(GRADLE) --version | head -5
|
||||||
|
|
||||||
|
##@ Maintenance
|
||||||
|
|
||||||
|
.PHONY: refresh
|
||||||
|
refresh: ## Refresh Gradle dependencies
|
||||||
|
@$(GRADLE) --refresh-dependencies
|
||||||
|
|
||||||
|
.PHONY: setup
|
||||||
|
setup: ## Initial setup (first time)
|
||||||
|
@echo "$(COLOR_GREEN)Running initial setup...$(COLOR_RESET)"
|
||||||
|
@$(GRADLE) wrapper
|
||||||
|
@echo "$(COLOR_GREEN)Setup complete! Run 'make build' to build the mod.$(COLOR_RESET)"
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all: clean build ## Clean and build
|
||||||
123
README.md
123
README.md
@@ -1 +1,122 @@
|
|||||||
# Open-TiedUp!
|
# TiedUp! - Minecraft 1.20.1 Forge Mod
|
||||||
|
|
||||||
|
Community remake of the TiedUp! mod for Minecraft 1.20.1 (Forge).
|
||||||
|
Adds restraint and roleplay mechanics to the game.
|
||||||
|
|
||||||
|
> Original mod by Yuti & Marl Velius (1.12.2).
|
||||||
|
> This is an independent remake, not affiliated with the original developers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Restraint items (binds, gags, blindfolds, collars, straps, and more)
|
||||||
|
- NPC entities with AI and personality-driven dialogue (Kidnapper, Damsel, Guard, Trader, Maid, Master)
|
||||||
|
- Kidnapper camp world generation
|
||||||
|
- Captivity and prison mechanics
|
||||||
|
- Player animation system with 3D item rendering
|
||||||
|
- Multiplayer synchronization
|
||||||
|
- Mod compatibility (Minecraft Comes Alive, Wildfire's Female Gender Mod)
|
||||||
|
- In-game guide book (Patchouli)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Java 17
|
||||||
|
- Minecraft 1.20.1
|
||||||
|
- Forge 47.4.10+
|
||||||
|
- [PlayerAnimator](https://maven.kosmx.dev/) 1.0.2-rc1+
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First time setup
|
||||||
|
make setup
|
||||||
|
|
||||||
|
# Build the mod
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Clean and rebuild
|
||||||
|
make rebuild
|
||||||
|
|
||||||
|
# See all available commands
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly with Gradle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
The built JAR will be in `build/libs/`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Minecraft client
|
||||||
|
make run
|
||||||
|
|
||||||
|
# Run dedicated server
|
||||||
|
make server
|
||||||
|
|
||||||
|
# Multiplayer testing (server + 2 clients)
|
||||||
|
make mptest
|
||||||
|
|
||||||
|
# Generate IDE configurations
|
||||||
|
make idea # IntelliJ IDEA
|
||||||
|
make eclipse # Eclipse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/tiedup/remake/
|
||||||
|
├── blocks/ # Custom blocks and block entities
|
||||||
|
├── cells/ # Captive cell management
|
||||||
|
├── client/ # Rendering, GUI, animations
|
||||||
|
├── commands/ # /tiedup command
|
||||||
|
├── compat/ # Mod compatibility (MCA, Wildfire)
|
||||||
|
├── core/ # Main mod class, config, sounds
|
||||||
|
├── dialogue/ # NPC conversation system
|
||||||
|
├── entities/ # Custom NPCs and AI
|
||||||
|
├── events/ # Event handlers
|
||||||
|
├── items/ # All mod items
|
||||||
|
├── mixin/ # Minecraft bytecode modifications
|
||||||
|
├── network/ # Multiplayer packet system
|
||||||
|
├── personality/ # NPC personality system
|
||||||
|
├── state/ # Player state tracking
|
||||||
|
├── v2/ # Next-gen items and blocks
|
||||||
|
└── worldgen/ # Structure generation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Some dependencies are included as local JARs in `libs/` because they are not available on Maven Central:
|
||||||
|
|
||||||
|
| Library | Version | Usage |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| PlayerAnimator | 1.0.2-rc1+1.20 | Player pose animations |
|
||||||
|
| bendy-lib | 4.0.0 | Model part bending |
|
||||||
|
| Architectury | 9.2.14 | Required by MCA |
|
||||||
|
| Minecraft Comes Alive | 7.6.13 | Optional compatibility |
|
||||||
|
| Wildfire Gender Mod | 3.1 | Optional compatibility |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
**TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall.
|
||||||
|
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission otherwise me.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This mod is under heavy rework. Things will break, APIs will change, features will come and go. If you want to build and use it as-is, that's on you.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome. Rules:
|
||||||
|
|
||||||
|
- **Pull requests only** - no direct pushes
|
||||||
|
- **Clear commit messages** - describe what and why, not how
|
||||||
|
- **Test your changes** before submitting - at minimum, make sure it compiles and runs
|
||||||
|
- Bug fixes, new features, improvements - all welcome
|
||||||
|
- Areas where help is especially needed: textures, 3D models, multiplayer testing
|
||||||
|
|||||||
24
build-with-java17.sh
Executable file
24
build-with-java17.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# TiedUp! Build Helper Script
|
||||||
|
# Forces the use of Java 17 for Gradle builds (required for Forge 1.20.1)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-with-java17.sh # Run default build
|
||||||
|
# ./build-with-java17.sh clean # Clean build
|
||||||
|
# ./build-with-java17.sh runClient # Run client
|
||||||
|
|
||||||
|
# Set Java 17 as JAVA_HOME for this script
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
|
||||||
|
|
||||||
|
# Print Java version for confirmation
|
||||||
|
echo "Using Java version:"
|
||||||
|
$JAVA_HOME/bin/java -version
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running Gradle with Java 17..."
|
||||||
|
echo "Command: ./gradlew $@"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run Gradle with all passed arguments
|
||||||
|
./gradlew "$@"
|
||||||
295
build.gradle
Normal file
295
build.gradle
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
// MixinGradle - must be in buildscript BEFORE plugins
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
maven { url = 'https://repo.spongepowered.org/repository/maven-public/' }
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'org.spongepowered:mixingradle:0.7-SNAPSHOT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'eclipse'
|
||||||
|
id 'idea'
|
||||||
|
id 'maven-publish'
|
||||||
|
id 'net.minecraftforge.gradle' version '[6.0,6.2)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mixin plugin AFTER ForgeGradle
|
||||||
|
apply plugin: 'org.spongepowered.mixin'
|
||||||
|
|
||||||
|
version = mod_version
|
||||||
|
group = mod_group_id
|
||||||
|
|
||||||
|
base {
|
||||||
|
archivesName = mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17.
|
||||||
|
java.toolchain.languageVersion = JavaLanguageVersion.of(17)
|
||||||
|
|
||||||
|
println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"
|
||||||
|
minecraft {
|
||||||
|
// The mappings can be changed at any time and must be in the following format.
|
||||||
|
// Channel: Version:
|
||||||
|
// official MCVersion Official field/method names from Mojang mapping files
|
||||||
|
// parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official
|
||||||
|
//
|
||||||
|
// You must be aware of the Mojang license when using the 'official' or 'parchment' mappings.
|
||||||
|
// See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
|
||||||
|
//
|
||||||
|
// Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge
|
||||||
|
// Additional setup is needed to use their mappings: https://parchmentmc.org/docs/getting-started
|
||||||
|
//
|
||||||
|
// Use non-default mappings at your own risk. They may not always work.
|
||||||
|
// Simply re-run your setup task after changing the mappings to update your workspace.
|
||||||
|
mappings channel: mapping_channel, version: mapping_version
|
||||||
|
|
||||||
|
// When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game.
|
||||||
|
// In most cases, it is not necessary to enable.
|
||||||
|
// enableEclipsePrepareRuns = true
|
||||||
|
// enableIdeaPrepareRuns = true
|
||||||
|
|
||||||
|
// This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game.
|
||||||
|
// It is REQUIRED to be set to true for this template to function.
|
||||||
|
// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html
|
||||||
|
copyIdeResources = true
|
||||||
|
|
||||||
|
// When true, this property will add the folder name of all declared run configurations to generated IDE run configurations.
|
||||||
|
// The folder name can be set on a run configuration using the "folderName" property.
|
||||||
|
// By default, the folder name of a run configuration is the name of the Gradle project containing it.
|
||||||
|
// generateRunFolders = true
|
||||||
|
|
||||||
|
// This property enables access transformers for use in development.
|
||||||
|
// They will be applied to the Minecraft artifact.
|
||||||
|
// The access transformer file can be anywhere in the project.
|
||||||
|
// However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge.
|
||||||
|
// This default location is a best practice to automatically put the file in the right place in the final jar.
|
||||||
|
// See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information.
|
||||||
|
// accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg')
|
||||||
|
|
||||||
|
// Default run configurations.
|
||||||
|
// These can be tweaked, removed, or duplicated as needed.
|
||||||
|
runs {
|
||||||
|
// applies to all the run configs below
|
||||||
|
configureEach {
|
||||||
|
workingDirectory project.file('run')
|
||||||
|
|
||||||
|
// Recommended logging data for a userdev environment
|
||||||
|
// The markers can be added/remove as needed separated by commas.
|
||||||
|
// "SCAN": For mods scan.
|
||||||
|
// "REGISTRIES": For firing of registry events.
|
||||||
|
// "REGISTRYDUMP": For getting the contents of all registries.
|
||||||
|
property 'forge.logging.markers', 'REGISTRIES'
|
||||||
|
|
||||||
|
// Recommended logging level for the console
|
||||||
|
// You can set various levels here.
|
||||||
|
// Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels
|
||||||
|
property 'forge.logging.console.level', 'debug'
|
||||||
|
|
||||||
|
mods {
|
||||||
|
"${mod_id}" {
|
||||||
|
source sourceSets.main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client {
|
||||||
|
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
|
||||||
|
property 'forge.enabledGameTestNamespaces', mod_id
|
||||||
|
|
||||||
|
// Mixin properties (required for dev environment)
|
||||||
|
property 'mixin.env.remapRefMap', 'true'
|
||||||
|
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"
|
||||||
|
|
||||||
|
// Mixin config arg
|
||||||
|
args '-mixin.config=tiedup.mixins.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
property 'forge.enabledGameTestNamespaces', mod_id
|
||||||
|
args '--nogui'
|
||||||
|
|
||||||
|
// Mixin properties (required for dev environment)
|
||||||
|
property 'mixin.env.remapRefMap', 'true'
|
||||||
|
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"
|
||||||
|
|
||||||
|
// Mixin config arg
|
||||||
|
args '-mixin.config=tiedup.mixins.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional client instances for multiplayer testing
|
||||||
|
client2 {
|
||||||
|
parent runs.client
|
||||||
|
workingDirectory project.file('run-client2')
|
||||||
|
args '--username', 'Dev2'
|
||||||
|
}
|
||||||
|
|
||||||
|
client3 {
|
||||||
|
parent runs.client
|
||||||
|
workingDirectory project.file('run-client3')
|
||||||
|
args '--username', 'Dev3'
|
||||||
|
}
|
||||||
|
|
||||||
|
// This run config launches GameTestServer and runs all registered gametests, then exits.
|
||||||
|
// By default, the server will crash when no gametests are provided.
|
||||||
|
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||||
|
gameTestServer {
|
||||||
|
property 'forge.enabledGameTestNamespaces', mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
data {
|
||||||
|
// example of overriding the workingDirectory set in configureEach above
|
||||||
|
workingDirectory project.file('run-data')
|
||||||
|
|
||||||
|
// Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources.
|
||||||
|
args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include resources generated by data generators.
|
||||||
|
sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||||
|
|
||||||
|
// MixinGradle configuration - generates refmap for production
|
||||||
|
mixin {
|
||||||
|
add sourceSets.main, 'tiedup.refmap.json'
|
||||||
|
config 'tiedup.mixins.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
// Put repositories for dependencies here
|
||||||
|
// ForgeGradle automatically adds the Forge maven and Maven Central for you
|
||||||
|
|
||||||
|
// PlayerAnimator library
|
||||||
|
maven {
|
||||||
|
name = "KosmX's maven"
|
||||||
|
url = 'https://maven.kosmx.dev/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patchouli
|
||||||
|
maven {
|
||||||
|
name = "Jared's maven"
|
||||||
|
url = 'https://maven.blamejared.com/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local libs directory for non-maven mods
|
||||||
|
flatDir {
|
||||||
|
dir 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Specify the version of Minecraft to use.
|
||||||
|
// Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact.
|
||||||
|
// The "userdev" classifier will be requested and setup by ForgeGradle.
|
||||||
|
// If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"],
|
||||||
|
// then special handling is done to allow a setup of a vanilla dependency without the use of an external repository.
|
||||||
|
minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}"
|
||||||
|
|
||||||
|
// Mixin annotation processor
|
||||||
|
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
|
||||||
|
|
||||||
|
// PlayerAnimator library for player pose animations
|
||||||
|
// Using flatDir repository for local jar
|
||||||
|
implementation fg.deobf("blank:player-animation-lib-forge:1.0.2-rc1+1.20")
|
||||||
|
|
||||||
|
// bendy-lib for bending model parts (knees, etc.)
|
||||||
|
// Using flatDir repository for local jar
|
||||||
|
implementation fg.deobf("blank:bendy-lib-forge:4.0.0")
|
||||||
|
|
||||||
|
// Patchouli
|
||||||
|
compileOnly fg.deobf("vazkii.patchouli:Patchouli:1.20.1-84-FORGE")
|
||||||
|
runtimeOnly fg.deobf("vazkii.patchouli:Patchouli:1.20.1-84-FORGE")
|
||||||
|
|
||||||
|
// Wildfire's Female Gender Mod (Optional Dependency)
|
||||||
|
// Using flatDir repository for proper deobfuscation
|
||||||
|
compileOnly fg.deobf("blank:wildfire-gender-mod:3.1")
|
||||||
|
runtimeOnly fg.deobf("blank:wildfire-gender-mod:3.1")
|
||||||
|
|
||||||
|
// Minecraft Comes Alive (Optional Dependency)
|
||||||
|
// Using flatDir repository for proper deobfuscation
|
||||||
|
compileOnly fg.deobf("blank:minecraft-comes-alive:7.6.13")
|
||||||
|
runtimeOnly fg.deobf("blank:minecraft-comes-alive:7.6.13")
|
||||||
|
|
||||||
|
// Architectury API (Required by MCA)
|
||||||
|
// Using flatDir repository for proper deobfuscation
|
||||||
|
compileOnly fg.deobf("blank:architectury:9.2.14-forge")
|
||||||
|
runtimeOnly fg.deobf("blank:architectury:9.2.14-forge")
|
||||||
|
|
||||||
|
// Example mod dependency with JEI - using fg.deobf() ensures the dependency is remapped to your development mappings
|
||||||
|
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
||||||
|
// compileOnly fg.deobf("mezz.jei:jei-${mc_version}-common-api:${jei_version}")
|
||||||
|
// compileOnly fg.deobf("mezz.jei:jei-${mc_version}-forge-api:${jei_version}")
|
||||||
|
// runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}-forge:${jei_version}")
|
||||||
|
|
||||||
|
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||||
|
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||||
|
// The group id is ignored when searching -- in this case, it is "blank"
|
||||||
|
// implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}")
|
||||||
|
|
||||||
|
// For more info:
|
||||||
|
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
||||||
|
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||||
|
}
|
||||||
|
|
||||||
|
// This block of code expands all declared replace properties in the specified resource targets.
|
||||||
|
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
|
||||||
|
// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments.
|
||||||
|
// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html
|
||||||
|
tasks.named('processResources', ProcessResources).configure {
|
||||||
|
var replaceProperties = [
|
||||||
|
minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range,
|
||||||
|
forge_version: forge_version, forge_version_range: forge_version_range,
|
||||||
|
loader_version_range: loader_version_range,
|
||||||
|
mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version,
|
||||||
|
mod_authors: mod_authors, mod_description: mod_description,
|
||||||
|
]
|
||||||
|
inputs.properties replaceProperties
|
||||||
|
|
||||||
|
filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) {
|
||||||
|
expand replaceProperties + [project: project]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example for how to get properties into the manifest for reading at runtime.
|
||||||
|
tasks.named('jar', Jar).configure {
|
||||||
|
manifest {
|
||||||
|
attributes([
|
||||||
|
'Specification-Title' : mod_id,
|
||||||
|
'Specification-Vendor' : mod_authors,
|
||||||
|
'Specification-Version' : '1', // We are version 1 of ourselves
|
||||||
|
'Implementation-Title' : project.name,
|
||||||
|
'Implementation-Version' : project.jar.archiveVersion,
|
||||||
|
'Implementation-Vendor' : mod_authors,
|
||||||
|
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
|
||||||
|
'MixinConfigs' : 'tiedup.mixins.json'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the preferred method to reobfuscate your jar file
|
||||||
|
finalizedBy 'reobfJar'
|
||||||
|
}
|
||||||
|
|
||||||
|
// However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing:
|
||||||
|
// tasks.named('publish').configure {
|
||||||
|
// dependsOn 'reobfJar'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Example configuration to allow publishing using the maven-publish plugin
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
register('mavenJava', MavenPublication) {
|
||||||
|
artifact jar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url "file://${project.projectDir}/mcmodsrepo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation
|
||||||
|
}
|
||||||
1503
docs/ARTIST_GUIDE.md
Normal file
1503
docs/ARTIST_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
62
gradle.properties
Normal file
62
gradle.properties
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
|
||||||
|
# This is required to provide enough memory for the Minecraft decompilation process.
|
||||||
|
org.gradle.jvmargs=-Xmx3G
|
||||||
|
org.gradle.daemon=false
|
||||||
|
|
||||||
|
# Force Gradle to use Java 17 (required for Minecraft 1.20.1)
|
||||||
|
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk
|
||||||
|
|
||||||
|
|
||||||
|
## Environment Properties
|
||||||
|
|
||||||
|
# The Minecraft version must agree with the Forge version to get a valid artifact
|
||||||
|
minecraft_version=1.20.1
|
||||||
|
# The Minecraft version range can use any release version of Minecraft as bounds.
|
||||||
|
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
|
||||||
|
# as they do not follow standard versioning conventions.
|
||||||
|
minecraft_version_range=[1.20.1,1.21)
|
||||||
|
# The Forge version must agree with the Minecraft version to get a valid artifact
|
||||||
|
forge_version=47.4.10
|
||||||
|
# The Forge version range can use any version of Forge as bounds or match the loader version range
|
||||||
|
forge_version_range=[47,)
|
||||||
|
# The loader version range can only use the major version of Forge/FML as bounds
|
||||||
|
loader_version_range=[47,)
|
||||||
|
# The mapping channel to use for mappings.
|
||||||
|
# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"].
|
||||||
|
# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin.
|
||||||
|
#
|
||||||
|
# | Channel | Version | |
|
||||||
|
# |-----------|----------------------|--------------------------------------------------------------------------------|
|
||||||
|
# | official | MCVersion | Official field/method names from Mojang mapping files |
|
||||||
|
# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official |
|
||||||
|
#
|
||||||
|
# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings.
|
||||||
|
# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
|
||||||
|
#
|
||||||
|
# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge.
|
||||||
|
# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started
|
||||||
|
mapping_channel=official
|
||||||
|
# The mapping version to query from the mapping channel.
|
||||||
|
# This must match the format required by the mapping channel.
|
||||||
|
mapping_version=1.20.1
|
||||||
|
|
||||||
|
|
||||||
|
## Mod Properties
|
||||||
|
|
||||||
|
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
|
||||||
|
# Must match the String constant located in the main mod class annotated with @Mod.
|
||||||
|
mod_id=tiedup
|
||||||
|
# The human-readable display name for the mod.
|
||||||
|
mod_name=TiedUp
|
||||||
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
|
mod_license=GPL-3.0 WITH Commons-Clause (No Sale/Paywall)
|
||||||
|
# The mod version. See https://semver.org/
|
||||||
|
mod_version=0.5.6-ALPHA
|
||||||
|
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||||
|
# This should match the base package used for the mod sources.
|
||||||
|
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||||
|
mod_group_id=com.tiedup.remake
|
||||||
|
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
|
||||||
|
mod_authors=Unknown
|
||||||
|
# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list.
|
||||||
|
mod_description=Community remake of the TiedUp! mod for Minecraft 1.20.1.\nAdds restraint and roleplay mechanics.\n\nOriginal mod by Yuti & Marl Velius (1.12.2)\nThis is an independent remake, not affiliated with original developers.
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
245
gradlew
vendored
Executable file
245
gradlew
vendored
Executable file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || 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
|
||||||
|
|
||||||
|
# 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=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=SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
92
gradlew.bat
vendored
Normal file
92
gradlew.bat
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
@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
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
BIN
libs/architectury-9.2.14-forge.jar
Normal file
BIN
libs/architectury-9.2.14-forge.jar
Normal file
Binary file not shown.
BIN
libs/bendy-lib-forge-4.0.0.jar
Normal file
BIN
libs/bendy-lib-forge-4.0.0.jar
Normal file
Binary file not shown.
BIN
libs/minecraft-comes-alive-7.6.13.jar
Normal file
BIN
libs/minecraft-comes-alive-7.6.13.jar
Normal file
Binary file not shown.
BIN
libs/player-animation-lib-forge-1.0.2-rc1+1.20.jar
Normal file
BIN
libs/player-animation-lib-forge-1.0.2-rc1+1.20.jar
Normal file
Binary file not shown.
BIN
libs/wildfire-gender-mod-3.1.jar
Normal file
BIN
libs/wildfire-gender-mod-3.1.jar
Normal file
Binary file not shown.
13
settings.gradle
Normal file
13
settings.gradle
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven {
|
||||||
|
name = 'MinecraftForge'
|
||||||
|
url = 'https://maven.minecraftforge.net/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0'
|
||||||
|
}
|
||||||
288
src/main/java/com/tiedup/remake/blocks/BlockCellCore.java
Normal file
288
src/main/java/com/tiedup/remake/blocks/BlockCellCore.java
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.CellCoreBlockEntity;
|
||||||
|
import com.tiedup.remake.cells.*;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.network.ModNetwork;
|
||||||
|
import com.tiedup.remake.network.cell.PacketOpenCoreMenu;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.material.MapColor;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||||
|
import net.minecraft.world.phys.shapes.Shapes;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Core block - the anchor for a Cell System V2 cell.
|
||||||
|
*
|
||||||
|
* Placed into a wall of an enclosed room. On placement, runs flood-fill to
|
||||||
|
* detect the room boundaries and registers a new cell. On removal, destroys
|
||||||
|
* the cell.
|
||||||
|
*
|
||||||
|
* Obsidian-tier hardness to prevent easy breaking by prisoners.
|
||||||
|
*/
|
||||||
|
public class BlockCellCore extends BaseEntityBlock {
|
||||||
|
|
||||||
|
public BlockCellCore() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.mapColor(MapColor.STONE)
|
||||||
|
.strength(50.0f, 1200.0f)
|
||||||
|
.requiresCorrectToolForDrops()
|
||||||
|
.sound(SoundType.STONE)
|
||||||
|
.noOcclusion()
|
||||||
|
.dynamicShape()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new CellCoreBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderShape getRenderShape(BlockState state) {
|
||||||
|
return RenderShape.MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DYNAMIC SHAPE (disguise-aware) ====================
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@Override
|
||||||
|
public VoxelShape getShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) {
|
||||||
|
BlockState disguise = core.getDisguiseState();
|
||||||
|
if (disguise == null) disguise = core.resolveDisguise();
|
||||||
|
if (disguise != null) {
|
||||||
|
return disguise.getShape(level, pos, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Shapes.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@Override
|
||||||
|
public VoxelShape getCollisionShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) {
|
||||||
|
BlockState disguise = core.getDisguiseState();
|
||||||
|
if (disguise == null) disguise = core.resolveDisguise();
|
||||||
|
if (disguise != null) {
|
||||||
|
return disguise.getCollisionShape(level, pos, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Shapes.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click handler: open the Cell Core menu for the cell owner.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@Override
|
||||||
|
public InteractionResult use(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Player player,
|
||||||
|
InteractionHand hand,
|
||||||
|
BlockHitResult hit
|
||||||
|
) {
|
||||||
|
if (level.isClientSide) {
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(player instanceof ServerPlayer serverPlayer)) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
if (
|
||||||
|
!(be instanceof CellCoreBlockEntity core) ||
|
||||||
|
core.getCellId() == null
|
||||||
|
) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
CellRegistryV2 registry = CellRegistryV2.get((ServerLevel) level);
|
||||||
|
CellDataV2 cell = registry.getCell(core.getCellId());
|
||||||
|
if (cell == null) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership (owner, camp cell, or OP level 2)
|
||||||
|
if (!cell.canPlayerManage(player.getUUID(), player.hasPermissions(2))) {
|
||||||
|
player.displayClientMessage(
|
||||||
|
Component.translatable("msg.tiedup.cell_core.not_owner"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return InteractionResult.CONSUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send packet with all cell stats
|
||||||
|
ModNetwork.sendToPlayer(
|
||||||
|
new PacketOpenCoreMenu(
|
||||||
|
pos,
|
||||||
|
cell.getId(),
|
||||||
|
cell.getName() != null ? cell.getName() : "",
|
||||||
|
cell.getState().getSerializedName(),
|
||||||
|
cell.getInteriorBlocks().size(),
|
||||||
|
cell.getWallBlocks().size(),
|
||||||
|
cell.getBreachedPositions().size(),
|
||||||
|
cell.getPrisonerCount(),
|
||||||
|
cell.getBeds().size(),
|
||||||
|
cell.getDoors().size(),
|
||||||
|
cell.getAnchors().size(),
|
||||||
|
core.getSpawnPoint() != null,
|
||||||
|
core.getDeliveryPoint() != null,
|
||||||
|
core.getDisguiseState() != null
|
||||||
|
),
|
||||||
|
serverPlayer
|
||||||
|
);
|
||||||
|
|
||||||
|
return InteractionResult.CONSUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On placement: run flood-fill to detect the room, create a cell if successful.
|
||||||
|
* If the room cannot be detected, break the block back and show an error.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void setPlacedBy(
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState state,
|
||||||
|
@Nullable LivingEntity placer,
|
||||||
|
ItemStack stack
|
||||||
|
) {
|
||||||
|
super.setPlacedBy(level, pos, state, placer, stack);
|
||||||
|
|
||||||
|
if (
|
||||||
|
level instanceof ServerLevel serverLevel &&
|
||||||
|
placer instanceof Player player
|
||||||
|
) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
if (!(be instanceof CellCoreBlockEntity core)) return;
|
||||||
|
|
||||||
|
FloodFillResult result = FloodFillAlgorithm.tryFill(level, pos);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||||
|
CellDataV2 cell = registry.createCell(
|
||||||
|
pos,
|
||||||
|
result,
|
||||||
|
player.getUUID()
|
||||||
|
);
|
||||||
|
|
||||||
|
core.setCellId(cell.getId());
|
||||||
|
core.setInteriorFace(result.getInteriorFace());
|
||||||
|
|
||||||
|
player.displayClientMessage(
|
||||||
|
Component.translatable(
|
||||||
|
"msg.tiedup.cell_core.created",
|
||||||
|
result.getInterior().size(),
|
||||||
|
result.getWalls().size()
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Failed — break the block back (can't form a cell here)
|
||||||
|
level.destroyBlock(pos, true);
|
||||||
|
player.displayClientMessage(
|
||||||
|
Component.translatable(result.getErrorKey()),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On removal: destroy the cell in the registry.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@Override
|
||||||
|
public void onRemove(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState newState,
|
||||||
|
boolean movedByPiston
|
||||||
|
) {
|
||||||
|
if (!state.is(newState.getBlock())) {
|
||||||
|
if (level instanceof ServerLevel serverLevel) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
if (
|
||||||
|
be instanceof CellCoreBlockEntity core &&
|
||||||
|
core.getCellId() != null
|
||||||
|
) {
|
||||||
|
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||||
|
CellDataV2 cell = registry.getCell(core.getCellId());
|
||||||
|
if (cell != null) {
|
||||||
|
cell.setState(CellState.COMPROMISED);
|
||||||
|
|
||||||
|
// Free all prisoners
|
||||||
|
for (UUID prisonerId : cell.getPrisonerIds()) {
|
||||||
|
registry.releasePrisoner(
|
||||||
|
cell.getId(),
|
||||||
|
prisonerId,
|
||||||
|
serverLevel.getServer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify owner
|
||||||
|
if (cell.getOwnerId() != null) {
|
||||||
|
ServerPlayer owner = serverLevel
|
||||||
|
.getServer()
|
||||||
|
.getPlayerList()
|
||||||
|
.getPlayer(cell.getOwnerId());
|
||||||
|
if (owner != null) {
|
||||||
|
String cellName =
|
||||||
|
cell.getName() != null
|
||||||
|
? cell.getName()
|
||||||
|
: "Cell " +
|
||||||
|
cell
|
||||||
|
.getId()
|
||||||
|
.toString()
|
||||||
|
.substring(0, 8);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
owner,
|
||||||
|
SystemMessageManager.MessageCategory.WARNING,
|
||||||
|
cellName + " has been destroyed!"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.removeCell(cell.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, newState, movedByPiston);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/tiedup/remake/blocks/BlockCellDoor.java
Normal file
33
src/main/java/com/tiedup/remake/blocks/BlockCellDoor.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import net.minecraft.world.level.block.DoorBlock;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.properties.BlockSetType;
|
||||||
|
import net.minecraft.world.level.material.MapColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Door Block - Iron-like door that cannot be opened by hand.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Cannot be opened by clicking (requires redstone)
|
||||||
|
* - Uses iron door behavior
|
||||||
|
* - Sturdy construction
|
||||||
|
*
|
||||||
|
* Based on original BlockKidnapDoorBase from 1.12.2
|
||||||
|
*/
|
||||||
|
public class BlockCellDoor extends DoorBlock {
|
||||||
|
|
||||||
|
public BlockCellDoor() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.mapColor(MapColor.METAL)
|
||||||
|
.strength(5.0f, 45.0f)
|
||||||
|
.sound(SoundType.METAL)
|
||||||
|
.requiresCorrectToolForDrops()
|
||||||
|
.noOcclusion(),
|
||||||
|
BlockSetType.IRON // J'ai remis moi même
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/main/java/com/tiedup/remake/blocks/BlockIronBarDoor.java
Normal file
346
src/main/java/com/tiedup/remake/blocks/BlockIronBarDoor.java
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.IronBarDoorBlockEntity;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.items.ItemCellKey;
|
||||||
|
import com.tiedup.remake.items.ItemKey;
|
||||||
|
import com.tiedup.remake.items.ItemMasterKey;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.LevelAccessor;
|
||||||
|
import net.minecraft.world.level.block.*;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.block.state.StateDefinition;
|
||||||
|
import net.minecraft.world.level.block.state.properties.*;
|
||||||
|
import net.minecraft.world.level.material.MapColor;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||||
|
import net.minecraft.world.phys.shapes.Shapes;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iron Bar Door - A door made of iron bars that can be locked.
|
||||||
|
*
|
||||||
|
* Phase: Kidnapper Revamp - Cell System
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Lockable with ItemKey (stores key UUID)
|
||||||
|
* - Can be unlocked with matching key, ItemCellKey, or ItemMasterKey
|
||||||
|
* - When locked, cannot be opened by redstone or by hand
|
||||||
|
* - Visually connects to iron bars (like vanilla iron bars)
|
||||||
|
* - Uses DoorBlock mechanics for open/close state
|
||||||
|
*/
|
||||||
|
public class BlockIronBarDoor extends DoorBlock implements EntityBlock {
|
||||||
|
|
||||||
|
// VoxelShapes for different orientations - centered like iron bars (7-9 = 2 pixels thick)
|
||||||
|
protected static final VoxelShape SOUTH_AABB = Block.box(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
7.0,
|
||||||
|
16.0,
|
||||||
|
16.0,
|
||||||
|
9.0
|
||||||
|
);
|
||||||
|
protected static final VoxelShape NORTH_AABB = Block.box(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
7.0,
|
||||||
|
16.0,
|
||||||
|
16.0,
|
||||||
|
9.0
|
||||||
|
);
|
||||||
|
protected static final VoxelShape WEST_AABB = Block.box(
|
||||||
|
7.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
9.0,
|
||||||
|
16.0,
|
||||||
|
16.0
|
||||||
|
);
|
||||||
|
protected static final VoxelShape EAST_AABB = Block.box(
|
||||||
|
7.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
9.0,
|
||||||
|
16.0,
|
||||||
|
16.0
|
||||||
|
);
|
||||||
|
|
||||||
|
public BlockIronBarDoor() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.mapColor(MapColor.METAL)
|
||||||
|
.strength(5.0f, 45.0f)
|
||||||
|
.sound(SoundType.METAL)
|
||||||
|
.requiresCorrectToolForDrops()
|
||||||
|
.noOcclusion(),
|
||||||
|
BlockSetType.IRON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SHAPE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
Direction facing = state.getValue(FACING);
|
||||||
|
boolean open = state.getValue(OPEN);
|
||||||
|
boolean hingeRight = state.getValue(HINGE) == DoorHingeSide.RIGHT;
|
||||||
|
|
||||||
|
Direction effectiveDirection;
|
||||||
|
if (open) {
|
||||||
|
effectiveDirection = hingeRight
|
||||||
|
? facing.getCounterClockWise()
|
||||||
|
: facing.getClockWise();
|
||||||
|
} else {
|
||||||
|
effectiveDirection = facing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (effectiveDirection) {
|
||||||
|
case NORTH -> NORTH_AABB;
|
||||||
|
case SOUTH -> SOUTH_AABB;
|
||||||
|
case WEST -> WEST_AABB;
|
||||||
|
case EAST -> EAST_AABB;
|
||||||
|
default -> SOUTH_AABB;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INTERACTION ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResult use(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Player player,
|
||||||
|
InteractionHand hand,
|
||||||
|
BlockHitResult hit
|
||||||
|
) {
|
||||||
|
// Get the BlockEntity
|
||||||
|
BlockEntity be = level.getBlockEntity(
|
||||||
|
getBlockEntityPos(state, pos, level)
|
||||||
|
);
|
||||||
|
if (!(be instanceof IronBarDoorBlockEntity doorEntity)) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack heldItem = player.getItemInHand(hand);
|
||||||
|
|
||||||
|
// Handle key interactions
|
||||||
|
if (heldItem.getItem() instanceof ItemMasterKey) {
|
||||||
|
// Master key can always unlock
|
||||||
|
if (doorEntity.isLocked()) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
doorEntity.setLocked(false);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.INFO,
|
||||||
|
"Door unlocked with master key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
} else if (heldItem.getItem() instanceof ItemCellKey) {
|
||||||
|
// Cell key can unlock any iron bar door
|
||||||
|
if (doorEntity.isLocked()) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
doorEntity.setLocked(false);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.INFO,
|
||||||
|
"Door unlocked with cell key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
} else if (heldItem.getItem() instanceof ItemKey key) {
|
||||||
|
UUID keyUUID = key.getKeyUUID(heldItem);
|
||||||
|
|
||||||
|
if (doorEntity.isLocked()) {
|
||||||
|
// Try to unlock
|
||||||
|
if (doorEntity.matchesKey(keyUUID)) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
doorEntity.setLocked(false);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.INFO,
|
||||||
|
"Door unlocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
} else {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.ERROR,
|
||||||
|
"This key doesn't fit this lock"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.FAIL;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Lock the door with this key
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
doorEntity.setLockedByKeyUUID(keyUUID);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.INFO,
|
||||||
|
"Door locked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If locked and no valid key, deny access
|
||||||
|
if (doorEntity.isLocked()) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.ERROR,
|
||||||
|
"This door is locked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InteractionResult.FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iron doors don't open by hand normally, but we allow it if unlocked
|
||||||
|
// Toggle the door state
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
boolean newOpen = !state.getValue(OPEN);
|
||||||
|
setOpen(player, level, state, pos, newOpen);
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the BlockEntity position (handles double-height doors).
|
||||||
|
*/
|
||||||
|
private BlockPos getBlockEntityPos(
|
||||||
|
BlockState state,
|
||||||
|
BlockPos pos,
|
||||||
|
Level level
|
||||||
|
) {
|
||||||
|
// LOW FIX: Check if HALF property exists before accessing (WorldEdit/SetBlock safety)
|
||||||
|
if (!state.hasProperty(HALF)) {
|
||||||
|
// Invalid state - assume lower half
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockEntity is always on the lower half
|
||||||
|
if (state.getValue(HALF) == DoubleBlockHalf.UPPER) {
|
||||||
|
return pos.below();
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== REDSTONE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void neighborChanged(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Block neighborBlock,
|
||||||
|
BlockPos neighborPos,
|
||||||
|
boolean movedByPiston
|
||||||
|
) {
|
||||||
|
// Check if locked before allowing redstone control
|
||||||
|
BlockEntity be = level.getBlockEntity(
|
||||||
|
getBlockEntityPos(state, pos, level)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
be instanceof IronBarDoorBlockEntity doorEntity &&
|
||||||
|
doorEntity.isLocked()
|
||||||
|
) {
|
||||||
|
// Door is locked, ignore redstone
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow normal redstone behavior if unlocked
|
||||||
|
super.neighborChanged(
|
||||||
|
state,
|
||||||
|
level,
|
||||||
|
pos,
|
||||||
|
neighborBlock,
|
||||||
|
neighborPos,
|
||||||
|
movedByPiston
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BLOCK ENTITY ====================
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
// Only create block entity for the lower half
|
||||||
|
if (state.getValue(HALF) == DoubleBlockHalf.LOWER) {
|
||||||
|
return new IronBarDoorBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PLACEMENT ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setPlacedBy(
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState state,
|
||||||
|
@Nullable net.minecraft.world.entity.LivingEntity placer,
|
||||||
|
ItemStack stack
|
||||||
|
) {
|
||||||
|
super.setPlacedBy(level, pos, state, placer, stack);
|
||||||
|
// BlockEntity is automatically created for lower half
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemove(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState newState,
|
||||||
|
boolean movedByPiston
|
||||||
|
) {
|
||||||
|
if (!state.is(newState.getBlock())) {
|
||||||
|
// Block entity is removed automatically
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, newState, movedByPiston);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VISUAL ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getShadeBrightness(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
// Allow some light through like iron bars
|
||||||
|
return 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean propagatesSkylightDown(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/main/java/com/tiedup/remake/blocks/BlockKidnapBomb.java
Normal file
294
src/main/java/com/tiedup/remake/blocks/BlockKidnapBomb.java
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.KidnapBombBlockEntity;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.entities.EntityKidnapBomb;
|
||||||
|
import com.tiedup.remake.util.BondageItemLoaderUtility;
|
||||||
|
import java.util.List;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.sounds.SoundEvents;
|
||||||
|
import net.minecraft.sounds.SoundSource;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.EntityBlock;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.TntBlock;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.gameevent.GameEvent;
|
||||||
|
import net.minecraft.world.level.storage.loot.LootParams;
|
||||||
|
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kidnap Bomb Block - TNT that applies bondage on explosion.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - TNT-like block that can be ignited
|
||||||
|
* - Stores bondage items via BlockEntity
|
||||||
|
* - On explosion, applies items to entities in radius (no block damage)
|
||||||
|
* - Can be loaded by right-clicking with bondage items
|
||||||
|
*
|
||||||
|
* Based on original BlockKidnapBomb from 1.12.2
|
||||||
|
*/
|
||||||
|
public class BlockKidnapBomb
|
||||||
|
extends TntBlock
|
||||||
|
implements EntityBlock, ICanBeLoaded
|
||||||
|
{
|
||||||
|
|
||||||
|
public BlockKidnapBomb() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.strength(0.0f)
|
||||||
|
.sound(SoundType.GRASS)
|
||||||
|
.ignitedByLava()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLOCK ENTITY
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new KidnapBombBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public KidnapBombBlockEntity getBombEntity(
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
return be instanceof KidnapBombBlockEntity
|
||||||
|
? (KidnapBombBlockEntity) be
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPLOSION HANDLING
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCaughtFire(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
@Nullable net.minecraft.core.Direction face,
|
||||||
|
@Nullable LivingEntity igniter
|
||||||
|
) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
KidnapBombBlockEntity bombTile = getBombEntity(level, pos);
|
||||||
|
explode(level, pos, bombTile, igniter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the primed kidnap bomb entity.
|
||||||
|
*/
|
||||||
|
public void explode(
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
@Nullable KidnapBombBlockEntity bombTile,
|
||||||
|
@Nullable LivingEntity igniter
|
||||||
|
) {
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
EntityKidnapBomb entity = new EntityKidnapBomb(
|
||||||
|
level,
|
||||||
|
pos.getX() + 0.5,
|
||||||
|
pos.getY(),
|
||||||
|
pos.getZ() + 0.5,
|
||||||
|
igniter,
|
||||||
|
bombTile
|
||||||
|
);
|
||||||
|
level.addFreshEntity(entity);
|
||||||
|
level.playSound(
|
||||||
|
null,
|
||||||
|
entity.getX(),
|
||||||
|
entity.getY(),
|
||||||
|
entity.getZ(),
|
||||||
|
SoundEvents.TNT_PRIMED,
|
||||||
|
SoundSource.BLOCKS,
|
||||||
|
1.0f,
|
||||||
|
1.0f
|
||||||
|
);
|
||||||
|
level.gameEvent(igniter, GameEvent.PRIME_FUSE, pos);
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BlockKidnapBomb] Bomb primed at {} by {}",
|
||||||
|
pos,
|
||||||
|
igniter != null ? igniter.getName().getString() : "unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOADING ITEMS
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResult use(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Player player,
|
||||||
|
InteractionHand hand,
|
||||||
|
BlockHitResult hit
|
||||||
|
) {
|
||||||
|
ItemStack heldItem = player.getItemInHand(hand);
|
||||||
|
|
||||||
|
// First check if it's flint and steel (default TNT behavior)
|
||||||
|
if (
|
||||||
|
heldItem.is(Items.FLINT_AND_STEEL) || heldItem.is(Items.FIRE_CHARGE)
|
||||||
|
) {
|
||||||
|
return super.use(state, level, pos, player, hand, hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hand != InteractionHand.MAIN_HAND) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heldItem.isEmpty()) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a bondage item
|
||||||
|
if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side only
|
||||||
|
if (level.isClientSide) {
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
KidnapBombBlockEntity bomb = getBombEntity(level, pos);
|
||||||
|
if (bomb == null) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load the held item into the appropriate slot
|
||||||
|
if (
|
||||||
|
BondageItemLoaderUtility.loadItemIntoHolder(bomb, heldItem, player)
|
||||||
|
) {
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
"Item loaded into bomb",
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
);
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROPS WITH NBT
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ItemStack> getDrops(
|
||||||
|
BlockState state,
|
||||||
|
LootParams.Builder params
|
||||||
|
) {
|
||||||
|
BlockEntity be = params.getOptionalParameter(
|
||||||
|
LootContextParams.BLOCK_ENTITY
|
||||||
|
);
|
||||||
|
ItemStack stack = new ItemStack(this);
|
||||||
|
|
||||||
|
if (be instanceof KidnapBombBlockEntity bomb) {
|
||||||
|
CompoundTag beTag = new CompoundTag();
|
||||||
|
bomb.writeBondageData(beTag);
|
||||||
|
|
||||||
|
if (!beTag.isEmpty()) {
|
||||||
|
stack.addTagElement("BlockEntityTag", beTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.of(stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOOLTIP
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void appendHoverText(
|
||||||
|
ItemStack stack,
|
||||||
|
@Nullable BlockGetter level,
|
||||||
|
List<Component> tooltip,
|
||||||
|
TooltipFlag flag
|
||||||
|
) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.translatable("block.tiedup.kidnap_bomb.desc").withStyle(
|
||||||
|
ChatFormatting.GRAY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CompoundTag nbt = stack.getTag();
|
||||||
|
if (nbt != null && nbt.contains("BlockEntityTag")) {
|
||||||
|
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
|
||||||
|
|
||||||
|
// Check if loaded with any items
|
||||||
|
if (
|
||||||
|
beTag.contains("bind") ||
|
||||||
|
beTag.contains("gag") ||
|
||||||
|
beTag.contains("blindfold") ||
|
||||||
|
beTag.contains("earplugs") ||
|
||||||
|
beTag.contains("collar")
|
||||||
|
) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Loaded:").withStyle(
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// List loaded items
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"bind"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"gag"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"blindfold"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"earplugs"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"collar"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"clothes"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/main/java/com/tiedup/remake/blocks/BlockMarker.java
Normal file
247
src/main/java/com/tiedup/remake/blocks/BlockMarker.java
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
|
||||||
|
import com.tiedup.remake.blocks.entity.ModBlockEntities;
|
||||||
|
import com.tiedup.remake.cells.MarkerType;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.entities.EntityKidnapper;
|
||||||
|
import com.tiedup.remake.items.ItemAdminWand;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.particles.ParticleTypes;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.util.RandomSource;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.material.MapColor;
|
||||||
|
import net.minecraft.world.level.material.PushReaction;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||||
|
import net.minecraft.world.phys.shapes.Shapes;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker Block - Invisible block used to define cell spawn points.
|
||||||
|
*
|
||||||
|
* Phase: Kidnapper Revamp - Cell System
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Invisible (no render)
|
||||||
|
* - No collision
|
||||||
|
* - Stores cell UUID via BlockEntity
|
||||||
|
* - Destroying removes the cell from registry
|
||||||
|
*
|
||||||
|
* Placed by structure generation or Admin Wand.
|
||||||
|
*/
|
||||||
|
public class BlockMarker extends BaseEntityBlock {
|
||||||
|
|
||||||
|
// Small shape for selection/picking
|
||||||
|
private static final VoxelShape SHAPE = Block.box(6, 6, 6, 10, 10, 10);
|
||||||
|
|
||||||
|
public BlockMarker() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.mapColor(MapColor.NONE)
|
||||||
|
.strength(0.5f) // Easy to break
|
||||||
|
.sound(SoundType.WOOD)
|
||||||
|
.noCollission()
|
||||||
|
.noOcclusion()
|
||||||
|
.pushReaction(PushReaction.DESTROY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SHAPE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
// Small hitbox for selection
|
||||||
|
return SHAPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getCollisionShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
// No collision
|
||||||
|
return Shapes.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getVisualShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
// No visual shape
|
||||||
|
return Shapes.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RENDER ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderShape getRenderShape(BlockState state) {
|
||||||
|
// Invisible - no model rendering
|
||||||
|
return RenderShape.INVISIBLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getShadeBrightness(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
// Full brightness (no shadow)
|
||||||
|
return 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean propagatesSkylightDown(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PARTICLE FEEDBACK ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn particles to make the marker visible when a player is holding a wand.
|
||||||
|
* This provides visual feedback for the otherwise invisible block.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void animateTick(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
RandomSource random
|
||||||
|
) {
|
||||||
|
// Only show particles if a nearby player is holding an AdminWand
|
||||||
|
Player nearestPlayer = level.getNearestPlayer(
|
||||||
|
pos.getX() + 0.5,
|
||||||
|
pos.getY() + 0.5,
|
||||||
|
pos.getZ() + 0.5,
|
||||||
|
16.0, // Detection range
|
||||||
|
false // Include spectators
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nearestPlayer == null) return;
|
||||||
|
|
||||||
|
// Check if player is holding an Admin Wand
|
||||||
|
boolean holdingWand =
|
||||||
|
nearestPlayer.getMainHandItem().getItem() instanceof
|
||||||
|
ItemAdminWand ||
|
||||||
|
nearestPlayer.getOffhandItem().getItem() instanceof ItemAdminWand;
|
||||||
|
|
||||||
|
if (!holdingWand) return;
|
||||||
|
|
||||||
|
// Spawn subtle particles at the marker position
|
||||||
|
// Use END_ROD for a glowing effect
|
||||||
|
if (random.nextInt(3) == 0) {
|
||||||
|
// 1 in 3 chance per tick for subtle effect
|
||||||
|
level.addParticle(
|
||||||
|
ParticleTypes.END_ROD,
|
||||||
|
pos.getX() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
|
||||||
|
pos.getY() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
|
||||||
|
pos.getZ() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
|
||||||
|
0,
|
||||||
|
0.02,
|
||||||
|
0 // Slight upward drift
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BLOCK ENTITY ====================
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new MarkerBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DESTRUCTION ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemove(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState newState,
|
||||||
|
boolean movedByPiston
|
||||||
|
) {
|
||||||
|
if (!state.is(newState.getBlock())) {
|
||||||
|
// Block is being destroyed, remove the cell from registry
|
||||||
|
if (level instanceof ServerLevel serverLevel) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
if (be instanceof MarkerBlockEntity marker) {
|
||||||
|
// Alert kidnappers if this was a WALL marker (potential breach)
|
||||||
|
if (marker.getMarkerType() == MarkerType.WALL) {
|
||||||
|
alertNearbyKidnappersOfBreach(
|
||||||
|
serverLevel,
|
||||||
|
pos,
|
||||||
|
marker.getCellId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
marker.deleteCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, newState, movedByPiston);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert nearby kidnappers when a WALL marker is destroyed.
|
||||||
|
* This indicates a potential breach in the cell that prisoners could escape through.
|
||||||
|
*
|
||||||
|
* @param level The server level
|
||||||
|
* @param breachPos The position of the destroyed wall
|
||||||
|
* @param cellId The cell UUID (may be null)
|
||||||
|
*/
|
||||||
|
private void alertNearbyKidnappersOfBreach(
|
||||||
|
ServerLevel level,
|
||||||
|
BlockPos breachPos,
|
||||||
|
UUID cellId
|
||||||
|
) {
|
||||||
|
if (cellId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find kidnappers within 50 blocks
|
||||||
|
AABB searchBox = new AABB(breachPos).inflate(50, 20, 50);
|
||||||
|
List<EntityKidnapper> kidnappers = level.getEntitiesOfClass(
|
||||||
|
EntityKidnapper.class,
|
||||||
|
searchBox
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kidnappers.isEmpty()) {
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BlockMarker] WALL destroyed at {}, alerting {} kidnappers",
|
||||||
|
breachPos.toShortString(),
|
||||||
|
kidnappers.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (EntityKidnapper kidnapper : kidnappers) {
|
||||||
|
kidnapper.onCellBreach(breachPos, cellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
380
src/main/java/com/tiedup/remake/blocks/BlockRopeTrap.java
Normal file
380
src/main/java/com/tiedup/remake/blocks/BlockRopeTrap.java
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.TrapBlockEntity;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.IBondageState;
|
||||||
|
import com.tiedup.remake.util.BondageItemLoaderUtility;
|
||||||
|
import com.tiedup.remake.util.KidnappedHelper;
|
||||||
|
import java.util.List;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.LevelAccessor;
|
||||||
|
import net.minecraft.world.level.LevelReader;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.level.block.EntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.storage.loot.LootParams;
|
||||||
|
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||||
|
import net.minecraft.world.phys.shapes.Shapes;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rope Trap Block - Trap that ties up entities when they walk on it.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Flat block (1 pixel tall) placed on solid surfaces
|
||||||
|
* - Can be loaded with bondage items by right-clicking
|
||||||
|
* - When armed and entity walks on it, applies all stored items
|
||||||
|
* - Destroys itself after triggering
|
||||||
|
* - Preserves NBT data when broken
|
||||||
|
*
|
||||||
|
* Based on original BlockRopesTrap from 1.12.2
|
||||||
|
*/
|
||||||
|
public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
|
||||||
|
|
||||||
|
// Shape: 1 pixel tall carpet-like block
|
||||||
|
protected static final VoxelShape TRAP_SHAPE = Block.box(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
16.0,
|
||||||
|
1.0,
|
||||||
|
16.0
|
||||||
|
);
|
||||||
|
|
||||||
|
public BlockRopeTrap() {
|
||||||
|
super(
|
||||||
|
BlockBehaviour.Properties.of()
|
||||||
|
.strength(1.0f, 0.5f)
|
||||||
|
.sound(SoundType.WOOL)
|
||||||
|
.noOcclusion()
|
||||||
|
.noCollission() // Entities can walk through/on it
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHAPE AND RENDERING
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
return TRAP_SHAPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VoxelShape getCollisionShape(
|
||||||
|
BlockState state,
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos,
|
||||||
|
CollisionContext context
|
||||||
|
) {
|
||||||
|
return Shapes.empty(); // No collision - entities walk through
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderShape getRenderShape(BlockState state) {
|
||||||
|
return RenderShape.MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLACEMENT RULES
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean canSurvive(
|
||||||
|
BlockState state,
|
||||||
|
LevelReader level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
BlockPos below = pos.below();
|
||||||
|
BlockState belowState = level.getBlockState(below);
|
||||||
|
// Can only be placed on full solid blocks
|
||||||
|
return belowState.isFaceSturdy(level, below, Direction.UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||||
|
if (
|
||||||
|
!canSurvive(
|
||||||
|
defaultBlockState(),
|
||||||
|
context.getLevel(),
|
||||||
|
context.getClickedPos()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return defaultBlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BlockState updateShape(
|
||||||
|
BlockState state,
|
||||||
|
Direction facing,
|
||||||
|
BlockState facingState,
|
||||||
|
LevelAccessor level,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockPos facingPos
|
||||||
|
) {
|
||||||
|
// Break if support block is removed
|
||||||
|
if (facing == Direction.DOWN && !canSurvive(state, level, pos)) {
|
||||||
|
return Blocks.AIR.defaultBlockState();
|
||||||
|
}
|
||||||
|
return super.updateShape(
|
||||||
|
state,
|
||||||
|
facing,
|
||||||
|
facingState,
|
||||||
|
level,
|
||||||
|
pos,
|
||||||
|
facingPos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLOCK ENTITY
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new TrapBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public TrapBlockEntity getTrapEntity(BlockGetter level, BlockPos pos) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
return be instanceof TrapBlockEntity ? (TrapBlockEntity) be : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRAP TRIGGER
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void entityInside(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Entity entity
|
||||||
|
) {
|
||||||
|
if (level.isClientSide) return;
|
||||||
|
|
||||||
|
// Only affect living entities
|
||||||
|
if (!(entity instanceof LivingEntity living)) return;
|
||||||
|
|
||||||
|
// Get target's kidnapped state
|
||||||
|
IBondageState targetState = KidnappedHelper.getKidnappedState(living);
|
||||||
|
if (targetState == null) return;
|
||||||
|
|
||||||
|
// Don't trigger if already tied
|
||||||
|
if (targetState.isTiedUp()) return;
|
||||||
|
|
||||||
|
// Get trap data
|
||||||
|
TrapBlockEntity trap = getTrapEntity(level, pos);
|
||||||
|
if (trap == null || !trap.isArmed()) return;
|
||||||
|
|
||||||
|
// Apply all bondage items
|
||||||
|
ItemStack bind = trap.getBind();
|
||||||
|
ItemStack gag = trap.getGag();
|
||||||
|
ItemStack blindfold = trap.getBlindfold();
|
||||||
|
ItemStack earplugs = trap.getEarplugs();
|
||||||
|
ItemStack collar = trap.getCollar();
|
||||||
|
ItemStack clothes = trap.getClothes();
|
||||||
|
|
||||||
|
targetState.applyBondage(
|
||||||
|
bind,
|
||||||
|
gag,
|
||||||
|
blindfold,
|
||||||
|
earplugs,
|
||||||
|
collar,
|
||||||
|
clothes
|
||||||
|
);
|
||||||
|
|
||||||
|
// Destroy the trap
|
||||||
|
level.destroyBlock(pos, false);
|
||||||
|
|
||||||
|
// Notify target
|
||||||
|
if (entity instanceof Player player) {
|
||||||
|
player.displayClientMessage(
|
||||||
|
Component.translatable("tiedup.trap.triggered").withStyle(
|
||||||
|
ChatFormatting.RED
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BlockRopeTrap] Trap triggered at {} on {}",
|
||||||
|
pos,
|
||||||
|
entity.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOADING ITEMS
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResult use(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Player player,
|
||||||
|
InteractionHand hand,
|
||||||
|
BlockHitResult hit
|
||||||
|
) {
|
||||||
|
if (hand != InteractionHand.MAIN_HAND) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack heldItem = player.getItemInHand(hand);
|
||||||
|
|
||||||
|
// Empty hand = do nothing
|
||||||
|
if (heldItem.isEmpty()) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a bondage item
|
||||||
|
if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side only
|
||||||
|
if (level.isClientSide) {
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrapBlockEntity trap = getTrapEntity(level, pos);
|
||||||
|
if (trap == null) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load the held item into the appropriate slot
|
||||||
|
if (
|
||||||
|
BondageItemLoaderUtility.loadItemIntoHolder(trap, heldItem, player)
|
||||||
|
) {
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
"Item loaded into trap",
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
);
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROPS WITH NBT
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ItemStack> getDrops(
|
||||||
|
BlockState state,
|
||||||
|
LootParams.Builder params
|
||||||
|
) {
|
||||||
|
BlockEntity be = params.getOptionalParameter(
|
||||||
|
LootContextParams.BLOCK_ENTITY
|
||||||
|
);
|
||||||
|
ItemStack stack = new ItemStack(this);
|
||||||
|
|
||||||
|
if (be instanceof TrapBlockEntity trap) {
|
||||||
|
CompoundTag beTag = new CompoundTag();
|
||||||
|
trap.writeBondageData(beTag);
|
||||||
|
|
||||||
|
if (!beTag.isEmpty()) {
|
||||||
|
stack.addTagElement("BlockEntityTag", beTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.of(stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOOLTIP
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void appendHoverText(
|
||||||
|
ItemStack stack,
|
||||||
|
@Nullable BlockGetter level,
|
||||||
|
List<Component> tooltip,
|
||||||
|
TooltipFlag flag
|
||||||
|
) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.translatable("block.tiedup.rope_trap.desc").withStyle(
|
||||||
|
ChatFormatting.GRAY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CompoundTag nbt = stack.getTag();
|
||||||
|
if (nbt != null && nbt.contains("BlockEntityTag")) {
|
||||||
|
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
|
||||||
|
|
||||||
|
// Check if armed
|
||||||
|
if (beTag.contains("bind")) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Armed").withStyle(
|
||||||
|
ChatFormatting.DARK_RED
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// List loaded items
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"bind"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"gag"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"blindfold"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"earplugs"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"collar"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"clothes"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Disarmed").withStyle(
|
||||||
|
ChatFormatting.GREEN
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/main/java/com/tiedup/remake/blocks/BlockTrappedChest.java
Normal file
245
src/main/java/com/tiedup/remake/blocks/BlockTrappedChest.java
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.entity.TrappedChestBlockEntity;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.items.base.*;
|
||||||
|
import com.tiedup.remake.state.IBondageState;
|
||||||
|
import com.tiedup.remake.util.BondageItemLoaderUtility;
|
||||||
|
import com.tiedup.remake.util.KidnappedHelper;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
|
import net.minecraft.world.level.BlockGetter;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.ChestBlock;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||||
|
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.storage.loot.LootParams;
|
||||||
|
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trapped Chest Block - Chest that traps players when opened.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Extends vanilla ChestBlock for proper chest behavior.
|
||||||
|
* Sneak + right-click to load bondage items.
|
||||||
|
* Normal open triggers the trap if armed.
|
||||||
|
*/
|
||||||
|
public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
|
||||||
|
|
||||||
|
public BlockTrappedChest() {
|
||||||
|
super(BlockBehaviour.Properties.of().strength(2.5f).noOcclusion(), () ->
|
||||||
|
com.tiedup.remake.blocks.entity.ModBlockEntities.TRAPPED_CHEST.get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLOCK ENTITY
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new TrappedChestBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public TrappedChestBlockEntity getTrapEntity(
|
||||||
|
BlockGetter level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
return be instanceof TrappedChestBlockEntity
|
||||||
|
? (TrappedChestBlockEntity) be
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INTERACTION - TRAP TRIGGER
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResult use(
|
||||||
|
BlockState state,
|
||||||
|
Level level,
|
||||||
|
BlockPos pos,
|
||||||
|
Player player,
|
||||||
|
InteractionHand hand,
|
||||||
|
BlockHitResult hit
|
||||||
|
) {
|
||||||
|
if (hand != InteractionHand.MAIN_HAND) {
|
||||||
|
return InteractionResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack heldItem = player.getItemInHand(hand);
|
||||||
|
|
||||||
|
// Check if holding a bondage item = load it (don't open chest)
|
||||||
|
if (BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
|
||||||
|
// Server-side only
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
TrappedChestBlockEntity chest = getTrapEntity(level, pos);
|
||||||
|
if (
|
||||||
|
chest != null &&
|
||||||
|
BondageItemLoaderUtility.loadItemIntoHolder(
|
||||||
|
chest,
|
||||||
|
heldItem,
|
||||||
|
player
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
"Item loaded into trap",
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal open - check for trap trigger first (server-side)
|
||||||
|
if (!level.isClientSide) {
|
||||||
|
TrappedChestBlockEntity chest = getTrapEntity(level, pos);
|
||||||
|
if (chest != null && chest.isArmed()) {
|
||||||
|
IBondageState playerState = KidnappedHelper.getKidnappedState(
|
||||||
|
player
|
||||||
|
);
|
||||||
|
if (playerState != null && !playerState.isTiedUp()) {
|
||||||
|
// Apply bondage
|
||||||
|
playerState.applyBondage(
|
||||||
|
chest.getBind(),
|
||||||
|
chest.getGag(),
|
||||||
|
chest.getBlindfold(),
|
||||||
|
chest.getEarplugs(),
|
||||||
|
chest.getCollar(),
|
||||||
|
chest.getClothes()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear the chest trap contents
|
||||||
|
chest.setBind(ItemStack.EMPTY);
|
||||||
|
chest.setGag(ItemStack.EMPTY);
|
||||||
|
chest.setBlindfold(ItemStack.EMPTY);
|
||||||
|
chest.setEarplugs(ItemStack.EMPTY);
|
||||||
|
chest.setCollar(ItemStack.EMPTY);
|
||||||
|
chest.setClothes(ItemStack.EMPTY);
|
||||||
|
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
player,
|
||||||
|
SystemMessageManager.MessageCategory.WARNING,
|
||||||
|
"You fell into a trap!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// FIX: Don't open chest GUI after trap triggers
|
||||||
|
return InteractionResult.SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal chest behavior (open GUI) - only if trap didn't trigger
|
||||||
|
return super.use(state, level, pos, player, hand, hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROPS WITH NBT
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ItemStack> getDrops(
|
||||||
|
BlockState state,
|
||||||
|
LootParams.Builder params
|
||||||
|
) {
|
||||||
|
List<ItemStack> drops = super.getDrops(state, params);
|
||||||
|
|
||||||
|
BlockEntity be = params.getOptionalParameter(
|
||||||
|
LootContextParams.BLOCK_ENTITY
|
||||||
|
);
|
||||||
|
if (be instanceof TrappedChestBlockEntity chest && chest.isArmed()) {
|
||||||
|
// Add trap data to the first drop (the chest itself)
|
||||||
|
if (!drops.isEmpty()) {
|
||||||
|
ItemStack stack = drops.get(0);
|
||||||
|
CompoundTag beTag = new CompoundTag();
|
||||||
|
chest.writeBondageData(beTag);
|
||||||
|
if (!beTag.isEmpty()) {
|
||||||
|
stack.addTagElement("BlockEntityTag", beTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return drops;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOOLTIP
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void appendHoverText(
|
||||||
|
ItemStack stack,
|
||||||
|
@Nullable BlockGetter level,
|
||||||
|
List<Component> tooltip,
|
||||||
|
TooltipFlag flag
|
||||||
|
) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.translatable("block.tiedup.trapped_chest.desc").withStyle(
|
||||||
|
ChatFormatting.GRAY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CompoundTag nbt = stack.getTag();
|
||||||
|
if (nbt != null && nbt.contains("BlockEntityTag")) {
|
||||||
|
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
|
||||||
|
|
||||||
|
if (
|
||||||
|
beTag.contains("bind") ||
|
||||||
|
beTag.contains("gag") ||
|
||||||
|
beTag.contains("blindfold") ||
|
||||||
|
beTag.contains("earplugs") ||
|
||||||
|
beTag.contains("collar")
|
||||||
|
) {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Armed").withStyle(
|
||||||
|
ChatFormatting.DARK_RED
|
||||||
|
)
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"bind"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"gag"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"blindfold"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"earplugs"
|
||||||
|
);
|
||||||
|
BondageItemLoaderUtility.addItemToTooltip(
|
||||||
|
tooltip,
|
||||||
|
beTag,
|
||||||
|
"collar"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Disarmed").withStyle(
|
||||||
|
ChatFormatting.GREEN
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tooltip.add(
|
||||||
|
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/tiedup/remake/blocks/ICanBeLoaded.java
Normal file
16
src/main/java/com/tiedup/remake/blocks/ICanBeLoaded.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker interface for blocks that can have bondage items loaded into them.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Implemented by:
|
||||||
|
* - BlockRopesTrap - applies items when entity walks on it
|
||||||
|
* - BlockTrappedBed - applies items when player sleeps
|
||||||
|
* - BlockKidnapBomb - passes items to explosion entity
|
||||||
|
*
|
||||||
|
* These blocks have associated BlockEntities that store the bondage items.
|
||||||
|
*/
|
||||||
|
public interface ICanBeLoaded {
|
||||||
|
// Marker interface - no methods required
|
||||||
|
}
|
||||||
214
src/main/java/com/tiedup/remake/blocks/ModBlocks.java
Normal file
214
src/main/java/com/tiedup/remake/blocks/ModBlocks.java
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package com.tiedup.remake.blocks;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.DoubleHighBlockItem;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.DoorBlock;
|
||||||
|
import net.minecraft.world.level.block.SlabBlock;
|
||||||
|
import net.minecraft.world.level.block.SoundType;
|
||||||
|
import net.minecraft.world.level.block.StairBlock;
|
||||||
|
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||||
|
import net.minecraft.world.level.material.MapColor;
|
||||||
|
import net.minecraftforge.registries.DeferredRegister;
|
||||||
|
import net.minecraftforge.registries.ForgeRegistries;
|
||||||
|
import net.minecraftforge.registries.RegistryObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mod Blocks Registration
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Handles registration of all TiedUp blocks using DeferredRegister.
|
||||||
|
*
|
||||||
|
* Blocks:
|
||||||
|
* - Padded block + variants (slab, stairs, pane)
|
||||||
|
* - Rope trap
|
||||||
|
* - Trapped bed
|
||||||
|
* - Cell door
|
||||||
|
* - Kidnap bomb
|
||||||
|
*/
|
||||||
|
public class ModBlocks {
|
||||||
|
|
||||||
|
// DeferredRegister for blocks
|
||||||
|
public static final DeferredRegister<Block> BLOCKS =
|
||||||
|
DeferredRegister.create(ForgeRegistries.BLOCKS, TiedUpMod.MOD_ID);
|
||||||
|
|
||||||
|
// DeferredRegister for block items (linked to ModItems)
|
||||||
|
public static final DeferredRegister<Item> BLOCK_ITEMS =
|
||||||
|
DeferredRegister.create(ForgeRegistries.ITEMS, TiedUpMod.MOD_ID);
|
||||||
|
|
||||||
|
// PADDED BLOCKS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base padded block properties.
|
||||||
|
* Cloth material, soft, quiet.
|
||||||
|
*/
|
||||||
|
private static BlockBehaviour.Properties paddedProperties() {
|
||||||
|
return BlockBehaviour.Properties.of()
|
||||||
|
.mapColor(MapColor.WOOL)
|
||||||
|
.strength(0.9f, 45.0f)
|
||||||
|
.sound(SoundType.WOOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padded Block - Basic soft block.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> PADDED_BLOCK = registerBlock(
|
||||||
|
"padded_block",
|
||||||
|
() -> new Block(paddedProperties())
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padded Slab - Half-height padded block.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> PADDED_SLAB = registerBlock(
|
||||||
|
"padded_slab",
|
||||||
|
() -> new SlabBlock(paddedProperties())
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padded Stairs - Stair variant of padded block.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> PADDED_STAIRS = registerBlock(
|
||||||
|
"padded_stairs",
|
||||||
|
() ->
|
||||||
|
new StairBlock(
|
||||||
|
() -> PADDED_BLOCK.get().defaultBlockState(),
|
||||||
|
paddedProperties()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TRAP BLOCKS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rope Trap - Flat trap that ties up entities that walk on it.
|
||||||
|
* Uses BlockEntity to store bondage items.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> ROPE_TRAP = registerBlock(
|
||||||
|
"rope_trap",
|
||||||
|
BlockRopeTrap::new
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kidnap Bomb - TNT that applies bondage on explosion.
|
||||||
|
* Uses BlockEntity to store bondage items.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> KIDNAP_BOMB = registerBlock(
|
||||||
|
"kidnap_bomb",
|
||||||
|
BlockKidnapBomb::new
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trapped Chest - Chest that traps players when opened.
|
||||||
|
* Uses BlockEntity to store bondage items.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> TRAPPED_CHEST = registerBlock(
|
||||||
|
"trapped_chest",
|
||||||
|
BlockTrappedChest::new
|
||||||
|
);
|
||||||
|
|
||||||
|
// DOOR BLOCKS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Door - Iron-like door that requires redstone to open.
|
||||||
|
* Cannot be opened by hand.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<BlockCellDoor> CELL_DOOR =
|
||||||
|
registerDoorBlock("cell_door", BlockCellDoor::new);
|
||||||
|
|
||||||
|
// CELL SYSTEM BLOCKS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker Block - Invisible block for cell spawn points.
|
||||||
|
* Stores cell UUID and links to CellRegistry.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> MARKER = registerBlockNoItem(
|
||||||
|
"marker",
|
||||||
|
BlockMarker::new
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iron Bar Door - Lockable door made of iron bars.
|
||||||
|
* Can be locked with keys and unlocked with matching key, cell key, or master key.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<BlockIronBarDoor> IRON_BAR_DOOR =
|
||||||
|
registerDoorBlock("iron_bar_door", BlockIronBarDoor::new);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Core - Anchor block for Cell System V2.
|
||||||
|
* Placed into a wall; runs flood-fill to detect the room and register a cell.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<Block> CELL_CORE = registerBlock(
|
||||||
|
"cell_core",
|
||||||
|
BlockCellCore::new
|
||||||
|
);
|
||||||
|
|
||||||
|
// REGISTRATION HELPERS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a block and its corresponding BlockItem.
|
||||||
|
*
|
||||||
|
* @param name Block registry name
|
||||||
|
* @param blockSupplier Block supplier
|
||||||
|
* @return RegistryObject for the block
|
||||||
|
*/
|
||||||
|
private static <T extends Block> RegistryObject<T> registerBlock(
|
||||||
|
String name,
|
||||||
|
Supplier<T> blockSupplier
|
||||||
|
) {
|
||||||
|
RegistryObject<T> block = BLOCKS.register(name, blockSupplier);
|
||||||
|
registerBlockItem(name, block);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a BlockItem for a block.
|
||||||
|
*
|
||||||
|
* @param name Item registry name (same as block)
|
||||||
|
* @param block The block to create an item for
|
||||||
|
*/
|
||||||
|
private static <T extends Block> void registerBlockItem(
|
||||||
|
String name,
|
||||||
|
RegistryObject<T> block
|
||||||
|
) {
|
||||||
|
BLOCK_ITEMS.register(name, () ->
|
||||||
|
new BlockItem(block.get(), new Item.Properties())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a block without an item.
|
||||||
|
* Used for blocks that need special item handling (e.g., trapped bed, doors).
|
||||||
|
*
|
||||||
|
* @param name Block registry name
|
||||||
|
* @param blockSupplier Block supplier
|
||||||
|
* @return RegistryObject for the block
|
||||||
|
*/
|
||||||
|
private static <T extends Block> RegistryObject<T> registerBlockNoItem(
|
||||||
|
String name,
|
||||||
|
Supplier<T> blockSupplier
|
||||||
|
) {
|
||||||
|
return BLOCKS.register(name, blockSupplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a door block with DoubleHighBlockItem.
|
||||||
|
* Doors are double-height blocks and need special item handling.
|
||||||
|
*
|
||||||
|
* @param name Block registry name
|
||||||
|
* @param blockSupplier Block supplier (must return DoorBlock or subclass)
|
||||||
|
* @return RegistryObject for the block
|
||||||
|
*/
|
||||||
|
private static <T extends DoorBlock> RegistryObject<T> registerDoorBlock(
|
||||||
|
String name,
|
||||||
|
Supplier<T> blockSupplier
|
||||||
|
) {
|
||||||
|
RegistryObject<T> block = BLOCKS.register(name, blockSupplier);
|
||||||
|
BLOCK_ITEMS.register(name, () ->
|
||||||
|
new DoubleHighBlockItem(block.get(), new Item.Properties())
|
||||||
|
);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.ItemBlindfold;
|
||||||
|
import com.tiedup.remake.items.base.ItemCollar;
|
||||||
|
import com.tiedup.remake.items.base.ItemEarplugs;
|
||||||
|
import com.tiedup.remake.items.base.ItemGag;
|
||||||
|
import com.tiedup.remake.items.clothes.GenericClothes;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base BlockEntity for blocks that store bondage items.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Stores up to 6 bondage items:
|
||||||
|
* - Bind (ropes, chains, straitjacket, etc.)
|
||||||
|
* - Gag
|
||||||
|
* - Blindfold
|
||||||
|
* - Earplugs
|
||||||
|
* - Collar
|
||||||
|
* - Clothes
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Full NBT serialization
|
||||||
|
* - Network synchronization for client rendering
|
||||||
|
* - Item type validation on load
|
||||||
|
*
|
||||||
|
* Based on original TileEntityBondageItemHandler from 1.12.2
|
||||||
|
*/
|
||||||
|
public abstract class BondageItemBlockEntity
|
||||||
|
extends BlockEntity
|
||||||
|
implements IBondageItemHolder
|
||||||
|
{
|
||||||
|
|
||||||
|
// STORED ITEMS
|
||||||
|
|
||||||
|
private ItemStack bind = ItemStack.EMPTY;
|
||||||
|
private ItemStack gag = ItemStack.EMPTY;
|
||||||
|
private ItemStack blindfold = ItemStack.EMPTY;
|
||||||
|
private ItemStack earplugs = ItemStack.EMPTY;
|
||||||
|
private ItemStack collar = ItemStack.EMPTY;
|
||||||
|
private ItemStack clothes = ItemStack.EMPTY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off-mode prevents network updates.
|
||||||
|
* Used when reading NBT for tooltips without affecting the world.
|
||||||
|
*/
|
||||||
|
private final boolean offMode;
|
||||||
|
|
||||||
|
// CONSTRUCTORS
|
||||||
|
|
||||||
|
public BondageItemBlockEntity(
|
||||||
|
BlockEntityType<?> type,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState state
|
||||||
|
) {
|
||||||
|
this(type, pos, state, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BondageItemBlockEntity(
|
||||||
|
BlockEntityType<?> type,
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState state,
|
||||||
|
boolean offMode
|
||||||
|
) {
|
||||||
|
super(type, pos, state);
|
||||||
|
this.offMode = offMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIND
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getBind() {
|
||||||
|
return this.bind;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBind(ItemStack bind) {
|
||||||
|
this.bind = bind != null ? bind : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GAG
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getGag() {
|
||||||
|
return this.gag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setGag(ItemStack gag) {
|
||||||
|
this.gag = gag != null ? gag : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLINDFOLD
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getBlindfold() {
|
||||||
|
return this.blindfold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBlindfold(ItemStack blindfold) {
|
||||||
|
this.blindfold = blindfold != null ? blindfold : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// EARPLUGS
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getEarplugs() {
|
||||||
|
return this.earplugs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setEarplugs(ItemStack earplugs) {
|
||||||
|
this.earplugs = earplugs != null ? earplugs : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLLAR
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getCollar() {
|
||||||
|
return this.collar;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCollar(ItemStack collar) {
|
||||||
|
this.collar = collar != null ? collar : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLOTHES
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getClothes() {
|
||||||
|
return this.clothes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClothes(ItemStack clothes) {
|
||||||
|
this.clothes = clothes != null ? clothes : ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATE
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isArmed() {
|
||||||
|
return !this.bind.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored bondage items.
|
||||||
|
* Called after applying items to a target.
|
||||||
|
*/
|
||||||
|
public void clearAllItems() {
|
||||||
|
this.bind = ItemStack.EMPTY;
|
||||||
|
this.gag = ItemStack.EMPTY;
|
||||||
|
this.blindfold = ItemStack.EMPTY;
|
||||||
|
this.earplugs = ItemStack.EMPTY;
|
||||||
|
this.collar = ItemStack.EMPTY;
|
||||||
|
this.clothes = ItemStack.EMPTY;
|
||||||
|
this.setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NBT SERIALIZATION
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load(CompoundTag tag) {
|
||||||
|
super.load(tag);
|
||||||
|
this.readBondageData(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void saveAdditional(CompoundTag tag) {
|
||||||
|
super.saveAdditional(tag);
|
||||||
|
this.writeBondageData(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBondageData(CompoundTag tag) {
|
||||||
|
// Read bind with type validation
|
||||||
|
if (tag.contains("bind")) {
|
||||||
|
ItemStack bindStack = ItemStack.of(tag.getCompound("bind"));
|
||||||
|
if (
|
||||||
|
!bindStack.isEmpty() && bindStack.getItem() instanceof ItemBind
|
||||||
|
) {
|
||||||
|
this.bind = bindStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read gag with type validation
|
||||||
|
if (tag.contains("gag")) {
|
||||||
|
ItemStack gagStack = ItemStack.of(tag.getCompound("gag"));
|
||||||
|
if (!gagStack.isEmpty() && gagStack.getItem() instanceof ItemGag) {
|
||||||
|
this.gag = gagStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read blindfold with type validation
|
||||||
|
if (tag.contains("blindfold")) {
|
||||||
|
ItemStack blindfoldStack = ItemStack.of(
|
||||||
|
tag.getCompound("blindfold")
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!blindfoldStack.isEmpty() &&
|
||||||
|
blindfoldStack.getItem() instanceof ItemBlindfold
|
||||||
|
) {
|
||||||
|
this.blindfold = blindfoldStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read earplugs with type validation
|
||||||
|
if (tag.contains("earplugs")) {
|
||||||
|
ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs"));
|
||||||
|
if (
|
||||||
|
!earplugsStack.isEmpty() &&
|
||||||
|
earplugsStack.getItem() instanceof ItemEarplugs
|
||||||
|
) {
|
||||||
|
this.earplugs = earplugsStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read collar with type validation
|
||||||
|
if (tag.contains("collar")) {
|
||||||
|
ItemStack collarStack = ItemStack.of(tag.getCompound("collar"));
|
||||||
|
if (
|
||||||
|
!collarStack.isEmpty() &&
|
||||||
|
collarStack.getItem() instanceof ItemCollar
|
||||||
|
) {
|
||||||
|
this.collar = collarStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read clothes with type validation
|
||||||
|
if (tag.contains("clothes")) {
|
||||||
|
ItemStack clothesStack = ItemStack.of(tag.getCompound("clothes"));
|
||||||
|
if (
|
||||||
|
!clothesStack.isEmpty() &&
|
||||||
|
clothesStack.getItem() instanceof GenericClothes
|
||||||
|
) {
|
||||||
|
this.clothes = clothesStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag writeBondageData(CompoundTag tag) {
|
||||||
|
if (!this.bind.isEmpty()) {
|
||||||
|
tag.put("bind", this.bind.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
if (!this.gag.isEmpty()) {
|
||||||
|
tag.put("gag", this.gag.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
if (!this.blindfold.isEmpty()) {
|
||||||
|
tag.put("blindfold", this.blindfold.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
if (!this.earplugs.isEmpty()) {
|
||||||
|
tag.put("earplugs", this.earplugs.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
if (!this.collar.isEmpty()) {
|
||||||
|
tag.put("collar", this.collar.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
if (!this.clothes.isEmpty()) {
|
||||||
|
tag.put("clothes", this.clothes.save(new CompoundTag()));
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NETWORK SYNC
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark dirty and sync to clients.
|
||||||
|
*/
|
||||||
|
protected void setChangedAndSync() {
|
||||||
|
if (!this.offMode && this.level != null) {
|
||||||
|
this.setChanged();
|
||||||
|
// Notify clients of block update
|
||||||
|
this.level.sendBlockUpdated(
|
||||||
|
this.worldPosition,
|
||||||
|
this.getBlockState(),
|
||||||
|
this.getBlockState(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag getUpdateTag() {
|
||||||
|
CompoundTag tag = super.getUpdateTag();
|
||||||
|
this.writeBondageData(tag);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleUpdateTag(CompoundTag tag) {
|
||||||
|
if (!this.offMode) {
|
||||||
|
this.readBondageData(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import com.tiedup.remake.cells.*;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.NbtUtils;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import net.minecraft.network.Connection;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraftforge.client.model.data.ModelData;
|
||||||
|
import net.minecraftforge.client.model.data.ModelProperty;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block entity for the Cell Core block.
|
||||||
|
*
|
||||||
|
* Stores the cell ID link, spawn/delivery points, disguise block,
|
||||||
|
* and which face of the Core points into the cell interior.
|
||||||
|
*/
|
||||||
|
public class CellCoreBlockEntity extends BlockEntity {
|
||||||
|
|
||||||
|
/** Shared ModelProperty for disguise — defined here (not in client-only CellCoreBakedModel) to avoid server classloading issues. */
|
||||||
|
public static final ModelProperty<BlockState> DISGUISE_PROPERTY =
|
||||||
|
new ModelProperty<>();
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private UUID cellId;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BlockPos spawnPoint;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BlockPos deliveryPoint;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BlockState disguiseState;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private Direction interiorFace;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private List<BlockPos> pathWaypoints;
|
||||||
|
|
||||||
|
/** Transient: pathWaypoints set by MarkerBlockEntity during V1→V2 conversion */
|
||||||
|
@Nullable
|
||||||
|
private transient List<BlockPos> pendingPathWaypoints;
|
||||||
|
|
||||||
|
public CellCoreBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.CELL_CORE.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LIFECYCLE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLoad() {
|
||||||
|
super.onLoad();
|
||||||
|
|
||||||
|
if (!(level instanceof ServerLevel serverLevel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a cellId but the cell isn't in CellRegistryV2,
|
||||||
|
// this is a structure-placed Core that needs flood-fill registration.
|
||||||
|
if (cellId != null) {
|
||||||
|
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||||
|
|
||||||
|
if (
|
||||||
|
registry.getCell(cellId) == null &&
|
||||||
|
registry.getCellAtCore(worldPosition) == null
|
||||||
|
) {
|
||||||
|
// Structure-placed Core: run flood-fill and create cell
|
||||||
|
FloodFillResult result = FloodFillAlgorithm.tryFill(
|
||||||
|
serverLevel,
|
||||||
|
worldPosition
|
||||||
|
);
|
||||||
|
|
||||||
|
CellDataV2 newCell;
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
newCell = registry.createCell(worldPosition, result, null);
|
||||||
|
if (result.getInteriorFace() != null) {
|
||||||
|
this.interiorFace = result.getInteriorFace();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flood-fill failed (e.g. chunk not fully loaded) — create minimal cell
|
||||||
|
newCell = new CellDataV2(UUID.randomUUID(), worldPosition);
|
||||||
|
registry.registerExistingCell(newCell);
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[CellCoreBlockEntity] Flood-fill failed at {}: {}. Created minimal cell.",
|
||||||
|
worldPosition.toShortString(),
|
||||||
|
result.getErrorKey()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update our cellId to match the new cell
|
||||||
|
this.cellId = newCell.getId();
|
||||||
|
|
||||||
|
// Transfer spawn/delivery to cell data
|
||||||
|
newCell.setSpawnPoint(this.spawnPoint);
|
||||||
|
newCell.setDeliveryPoint(this.deliveryPoint);
|
||||||
|
|
||||||
|
// Transfer pathWaypoints: persistent field (from NBT) or V1 migration pending
|
||||||
|
List<BlockPos> waypointsToTransfer = null;
|
||||||
|
if (
|
||||||
|
this.pathWaypoints != null && !this.pathWaypoints.isEmpty()
|
||||||
|
) {
|
||||||
|
waypointsToTransfer = this.pathWaypoints;
|
||||||
|
} else if (
|
||||||
|
this.pendingPathWaypoints != null &&
|
||||||
|
!this.pendingPathWaypoints.isEmpty()
|
||||||
|
) {
|
||||||
|
waypointsToTransfer = this.pendingPathWaypoints;
|
||||||
|
this.pendingPathWaypoints = null;
|
||||||
|
}
|
||||||
|
if (waypointsToTransfer != null) {
|
||||||
|
newCell.setPathWaypoints(waypointsToTransfer);
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CellCoreBlockEntity] Transferred {} pathWaypoints to cell {}",
|
||||||
|
waypointsToTransfer.size(),
|
||||||
|
cellId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as camp-owned and link to nearest camp
|
||||||
|
newCell.setOwnerType(CellOwnerType.CAMP);
|
||||||
|
|
||||||
|
CampOwnership ownership = CampOwnership.get(serverLevel);
|
||||||
|
CampOwnership.CampData nearestCamp =
|
||||||
|
ownership.findNearestAliveCamp(worldPosition, 40);
|
||||||
|
|
||||||
|
if (nearestCamp != null) {
|
||||||
|
newCell.setOwnerId(nearestCamp.getCampId());
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CellCoreBlockEntity] Created cell {} linked to camp {} at {}",
|
||||||
|
cellId.toString().substring(0, 8),
|
||||||
|
nearestCamp.getCampId().toString().substring(0, 8),
|
||||||
|
worldPosition.toShortString()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No camp yet: generate deterministic camp ID (same algo as MarkerBlockEntity)
|
||||||
|
UUID structureCampId = generateStructureCampId(
|
||||||
|
worldPosition
|
||||||
|
);
|
||||||
|
newCell.setOwnerId(structureCampId);
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CellCoreBlockEntity] Created cell {} with structure camp ID {} at {}",
|
||||||
|
cellId.toString().substring(0, 8),
|
||||||
|
structureCampId.toString().substring(0, 8),
|
||||||
|
worldPosition.toShortString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.updateCampIndex(newCell, null);
|
||||||
|
registry.setDirty();
|
||||||
|
setChangedAndSync();
|
||||||
|
} else {
|
||||||
|
// Existing cell: sync pathWaypoints from CellDataV2 → Core BE
|
||||||
|
// (so re-saving structures picks them up in the new offset format)
|
||||||
|
CellDataV2 existingCell = registry.getCell(cellId);
|
||||||
|
if (existingCell == null) {
|
||||||
|
existingCell = registry.getCellAtCore(worldPosition);
|
||||||
|
}
|
||||||
|
if (existingCell != null) {
|
||||||
|
if (
|
||||||
|
this.pathWaypoints == null ||
|
||||||
|
this.pathWaypoints.isEmpty()
|
||||||
|
) {
|
||||||
|
List<BlockPos> cellWaypoints =
|
||||||
|
existingCell.getPathWaypoints();
|
||||||
|
if (!cellWaypoints.isEmpty()) {
|
||||||
|
this.pathWaypoints = new ArrayList<>(cellWaypoints);
|
||||||
|
setChanged(); // persist without network sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deterministic camp ID based on structure position.
|
||||||
|
* Uses the same 128-block grid algorithm as MarkerBlockEntity.
|
||||||
|
*/
|
||||||
|
private static UUID generateStructureCampId(BlockPos pos) {
|
||||||
|
int gridX = (pos.getX() / 128) * 128;
|
||||||
|
int gridZ = (pos.getZ() / 128) * 128;
|
||||||
|
long mostSigBits = ((long) gridX << 32) | (gridZ & 0xFFFFFFFFL);
|
||||||
|
long leastSigBits = 0x8000000000000000L | (gridX ^ gridZ);
|
||||||
|
return new UUID(mostSigBits, leastSigBits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GETTERS/SETTERS ====================
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public UUID getCellId() {
|
||||||
|
return cellId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCellId(@Nullable UUID cellId) {
|
||||||
|
this.cellId = cellId;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockPos getSpawnPoint() {
|
||||||
|
return spawnPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpawnPoint(@Nullable BlockPos spawnPoint) {
|
||||||
|
this.spawnPoint = spawnPoint;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockPos getDeliveryPoint() {
|
||||||
|
return deliveryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) {
|
||||||
|
this.deliveryPoint = deliveryPoint;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockState getDisguiseState() {
|
||||||
|
return disguiseState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisguiseState(@Nullable BlockState disguiseState) {
|
||||||
|
this.disguiseState = disguiseState;
|
||||||
|
setChangedAndSync();
|
||||||
|
requestModelDataUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Direction getInteriorFace() {
|
||||||
|
return interiorFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInteriorFace(@Nullable Direction interiorFace) {
|
||||||
|
this.interiorFace = interiorFace;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public List<BlockPos> getPathWaypoints() {
|
||||||
|
return pathWaypoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPathWaypoints(@Nullable List<BlockPos> pathWaypoints) {
|
||||||
|
this.pathWaypoints = pathWaypoints;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPendingPathWaypoints(List<BlockPos> waypoints) {
|
||||||
|
this.pendingPathWaypoints = waypoints;
|
||||||
|
this.pathWaypoints = waypoints; // also persist
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MODEL DATA (Camouflage) ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull ModelData getModelData() {
|
||||||
|
BlockState disguise = resolveDisguise();
|
||||||
|
if (disguise != null) {
|
||||||
|
return ModelData.builder()
|
||||||
|
.with(DISGUISE_PROPERTY, disguise)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return ModelData.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockState resolveDisguise() {
|
||||||
|
// Explicit disguise takes priority (preserves full BlockState including slab type, etc.)
|
||||||
|
if (disguiseState != null) {
|
||||||
|
return disguiseState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect most common solid neighbor
|
||||||
|
if (level != null) {
|
||||||
|
return detectMostCommonNeighbor();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BlockState detectMostCommonNeighbor() {
|
||||||
|
if (level == null) return null;
|
||||||
|
|
||||||
|
// Track full BlockState (preserves slab type, stair facing, etc.)
|
||||||
|
Map<BlockState, Integer> counts = new HashMap<>();
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos neighborPos = worldPosition.relative(dir);
|
||||||
|
BlockState neighbor = level.getBlockState(neighborPos);
|
||||||
|
if (
|
||||||
|
neighbor.getBlock() instanceof
|
||||||
|
com.tiedup.remake.blocks.BlockCellCore
|
||||||
|
) continue;
|
||||||
|
if (
|
||||||
|
neighbor.isSolidRender(level, neighborPos) ||
|
||||||
|
neighbor.getBlock() instanceof
|
||||||
|
net.minecraft.world.level.block.SlabBlock
|
||||||
|
) {
|
||||||
|
counts.merge(neighbor, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts.isEmpty()) return null;
|
||||||
|
|
||||||
|
BlockState mostCommon = null;
|
||||||
|
int maxCount = 0;
|
||||||
|
for (Map.Entry<BlockState, Integer> entry : counts.entrySet()) {
|
||||||
|
if (entry.getValue() > maxCount) {
|
||||||
|
maxCount = entry.getValue();
|
||||||
|
mostCommon = entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mostCommon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NBT ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void saveAdditional(CompoundTag tag) {
|
||||||
|
super.saveAdditional(tag);
|
||||||
|
if (cellId != null) tag.putUUID("cellId", cellId);
|
||||||
|
// Save positions as relative offsets from core position (survives structure placement + rotation)
|
||||||
|
if (spawnPoint != null) {
|
||||||
|
tag.put(
|
||||||
|
"spawnOffset",
|
||||||
|
NbtUtils.writeBlockPos(toOffset(spawnPoint, worldPosition))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deliveryPoint != null) {
|
||||||
|
tag.put(
|
||||||
|
"deliveryOffset",
|
||||||
|
NbtUtils.writeBlockPos(toOffset(deliveryPoint, worldPosition))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathWaypoints != null && !pathWaypoints.isEmpty()) {
|
||||||
|
ListTag list = new ListTag();
|
||||||
|
for (BlockPos wp : pathWaypoints) {
|
||||||
|
list.add(NbtUtils.writeBlockPos(toOffset(wp, worldPosition)));
|
||||||
|
}
|
||||||
|
tag.put("pathWaypointOffsets", list);
|
||||||
|
}
|
||||||
|
if (disguiseState != null) tag.put(
|
||||||
|
"disguiseState",
|
||||||
|
NbtUtils.writeBlockState(disguiseState)
|
||||||
|
);
|
||||||
|
if (interiorFace != null) tag.putString(
|
||||||
|
"interiorFace",
|
||||||
|
interiorFace.getSerializedName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load(CompoundTag tag) {
|
||||||
|
super.load(tag);
|
||||||
|
cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null;
|
||||||
|
|
||||||
|
// Spawn point: new relative offset format, then old absolute fallback
|
||||||
|
if (tag.contains("spawnOffset")) {
|
||||||
|
spawnPoint = fromOffset(
|
||||||
|
NbtUtils.readBlockPos(tag.getCompound("spawnOffset")),
|
||||||
|
worldPosition
|
||||||
|
);
|
||||||
|
} else if (tag.contains("spawnPoint")) {
|
||||||
|
spawnPoint = NbtUtils.readBlockPos(tag.getCompound("spawnPoint"));
|
||||||
|
} else {
|
||||||
|
spawnPoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery point: new relative offset format, then old absolute fallback
|
||||||
|
if (tag.contains("deliveryOffset")) {
|
||||||
|
deliveryPoint = fromOffset(
|
||||||
|
NbtUtils.readBlockPos(tag.getCompound("deliveryOffset")),
|
||||||
|
worldPosition
|
||||||
|
);
|
||||||
|
} else if (tag.contains("deliveryPoint")) {
|
||||||
|
deliveryPoint = NbtUtils.readBlockPos(
|
||||||
|
tag.getCompound("deliveryPoint")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
deliveryPoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path waypoints (relative offsets)
|
||||||
|
if (tag.contains("pathWaypointOffsets")) {
|
||||||
|
ListTag list = tag.getList("pathWaypointOffsets", Tag.TAG_COMPOUND);
|
||||||
|
pathWaypoints = new ArrayList<>();
|
||||||
|
for (int i = 0; i < list.size(); i++) {
|
||||||
|
pathWaypoints.add(
|
||||||
|
fromOffset(
|
||||||
|
NbtUtils.readBlockPos(list.getCompound(i)),
|
||||||
|
worldPosition
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pathWaypoints = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrocompat: old saves stored "disguiseBlock" as ResourceLocation string
|
||||||
|
if (tag.contains("disguiseState")) {
|
||||||
|
disguiseState = NbtUtils.readBlockState(
|
||||||
|
BuiltInRegistries.BLOCK.asLookup(),
|
||||||
|
tag.getCompound("disguiseState")
|
||||||
|
);
|
||||||
|
} else if (tag.contains("disguiseBlock")) {
|
||||||
|
// V1 compat: convert old ResourceLocation to default BlockState
|
||||||
|
ResourceLocation oldId = ResourceLocation.tryParse(
|
||||||
|
tag.getString("disguiseBlock")
|
||||||
|
);
|
||||||
|
if (oldId != null) {
|
||||||
|
Block block = BuiltInRegistries.BLOCK.get(oldId);
|
||||||
|
disguiseState = (block !=
|
||||||
|
net.minecraft.world.level.block.Blocks.AIR)
|
||||||
|
? block.defaultBlockState()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disguiseState = null;
|
||||||
|
}
|
||||||
|
interiorFace = tag.contains("interiorFace")
|
||||||
|
? Direction.byName(tag.getString("interiorFace"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== OFFSET HELPERS ====================
|
||||||
|
|
||||||
|
/** Convert absolute position to relative offset from origin. */
|
||||||
|
private static BlockPos toOffset(BlockPos absolute, BlockPos origin) {
|
||||||
|
return new BlockPos(
|
||||||
|
absolute.getX() - origin.getX(),
|
||||||
|
absolute.getY() - origin.getY(),
|
||||||
|
absolute.getZ() - origin.getZ()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert relative offset back to absolute position. */
|
||||||
|
private static BlockPos fromOffset(BlockPos offset, BlockPos origin) {
|
||||||
|
return origin.offset(offset.getX(), offset.getY(), offset.getZ());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NETWORK SYNC ====================
|
||||||
|
|
||||||
|
private void setChangedAndSync() {
|
||||||
|
if (level != null) {
|
||||||
|
setChanged();
|
||||||
|
level.sendBlockUpdated(
|
||||||
|
worldPosition,
|
||||||
|
getBlockState(),
|
||||||
|
getBlockState(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag getUpdateTag() {
|
||||||
|
CompoundTag tag = super.getUpdateTag();
|
||||||
|
saveAdditional(tag);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDataPacket(
|
||||||
|
Connection net,
|
||||||
|
ClientboundBlockEntityDataPacket pkt
|
||||||
|
) {
|
||||||
|
CompoundTag tag = pkt.getTag();
|
||||||
|
if (tag != null) {
|
||||||
|
handleUpdateTag(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleUpdateTag(CompoundTag tag) {
|
||||||
|
load(tag);
|
||||||
|
requestModelDataUpdate();
|
||||||
|
// Force chunk section re-render to pick up new model data immediately.
|
||||||
|
// Enqueue to main thread since handleUpdateTag may run on network thread.
|
||||||
|
if (level != null && level.isClientSide()) {
|
||||||
|
net.minecraft.client.Minecraft.getInstance().tell(
|
||||||
|
this::markRenderDirty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the chunk section containing this block entity for re-rendering.
|
||||||
|
* Must be called on the client main thread only.
|
||||||
|
*/
|
||||||
|
private void markRenderDirty() {
|
||||||
|
if (level == null || !level.isClientSide()) return;
|
||||||
|
net.minecraft.client.Minecraft mc =
|
||||||
|
net.minecraft.client.Minecraft.getInstance();
|
||||||
|
if (mc.levelRenderer != null) {
|
||||||
|
mc.levelRenderer.blockChanged(
|
||||||
|
level,
|
||||||
|
worldPosition,
|
||||||
|
getBlockState(),
|
||||||
|
getBlockState(),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for BlockEntities that store bondage items.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Defines the contract for storing and retrieving bondage items:
|
||||||
|
* - Bind (ropes, chains, etc.)
|
||||||
|
* - Gag
|
||||||
|
* - Blindfold
|
||||||
|
* - Earplugs
|
||||||
|
* - Collar
|
||||||
|
* - Clothes
|
||||||
|
*
|
||||||
|
* Based on original ITileEntityBondageItemHolder from 1.12.2
|
||||||
|
*/
|
||||||
|
public interface IBondageItemHolder {
|
||||||
|
// BIND
|
||||||
|
|
||||||
|
ItemStack getBind();
|
||||||
|
void setBind(ItemStack bind);
|
||||||
|
|
||||||
|
// GAG
|
||||||
|
|
||||||
|
ItemStack getGag();
|
||||||
|
void setGag(ItemStack gag);
|
||||||
|
|
||||||
|
// BLINDFOLD
|
||||||
|
|
||||||
|
ItemStack getBlindfold();
|
||||||
|
void setBlindfold(ItemStack blindfold);
|
||||||
|
|
||||||
|
// EARPLUGS
|
||||||
|
|
||||||
|
ItemStack getEarplugs();
|
||||||
|
void setEarplugs(ItemStack earplugs);
|
||||||
|
|
||||||
|
// COLLAR
|
||||||
|
|
||||||
|
ItemStack getCollar();
|
||||||
|
void setCollar(ItemStack collar);
|
||||||
|
|
||||||
|
// CLOTHES
|
||||||
|
|
||||||
|
ItemStack getClothes();
|
||||||
|
void setClothes(ItemStack clothes);
|
||||||
|
|
||||||
|
// NBT SERIALIZATION
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read bondage items from NBT.
|
||||||
|
* @param tag The compound tag to read from
|
||||||
|
*/
|
||||||
|
void readBondageData(CompoundTag tag);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write bondage items to NBT.
|
||||||
|
* @param tag The compound tag to write to
|
||||||
|
* @return The modified compound tag
|
||||||
|
*/
|
||||||
|
CompoundTag writeBondageData(CompoundTag tag);
|
||||||
|
|
||||||
|
// STATE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this holder has any bondage items loaded.
|
||||||
|
* Typically checks if bind is present.
|
||||||
|
* @return true if armed/loaded
|
||||||
|
*/
|
||||||
|
boolean isArmed();
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.ILockable;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockEntity for iron bar door blocks.
|
||||||
|
*
|
||||||
|
* Phase: Kidnapper Revamp - Cell System
|
||||||
|
*
|
||||||
|
* Stores the lock state and key UUID for the door.
|
||||||
|
* Implements a block-based version of the ILockable pattern.
|
||||||
|
*/
|
||||||
|
public class IronBarDoorBlockEntity extends BlockEntity {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private UUID lockedByKeyUUID;
|
||||||
|
|
||||||
|
private boolean locked = false;
|
||||||
|
|
||||||
|
public IronBarDoorBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.IRON_BAR_DOOR.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LOCK STATE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this door is locked.
|
||||||
|
*/
|
||||||
|
public boolean isLocked() {
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the locked state.
|
||||||
|
*
|
||||||
|
* @param locked true to lock, false to unlock
|
||||||
|
*/
|
||||||
|
public void setLocked(boolean locked) {
|
||||||
|
this.locked = locked;
|
||||||
|
if (!locked) {
|
||||||
|
this.lockedByKeyUUID = null;
|
||||||
|
}
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== KEY UUID ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the UUID of the key that locked this door.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public UUID getLockedByKeyUUID() {
|
||||||
|
return lockedByKeyUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock this door with a specific key.
|
||||||
|
*
|
||||||
|
* @param keyUUID The key UUID, or null to unlock
|
||||||
|
*/
|
||||||
|
public void setLockedByKeyUUID(@Nullable UUID keyUUID) {
|
||||||
|
this.lockedByKeyUUID = keyUUID;
|
||||||
|
this.locked = keyUUID != null;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key matches this door's lock.
|
||||||
|
*
|
||||||
|
* @param keyUUID The key UUID to test
|
||||||
|
* @return true if the key matches
|
||||||
|
*/
|
||||||
|
public boolean matchesKey(UUID keyUUID) {
|
||||||
|
if (keyUUID == null) return false;
|
||||||
|
return lockedByKeyUUID != null && lockedByKeyUUID.equals(keyUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this door can be unlocked by the given key.
|
||||||
|
* Matches either the specific key or any master key.
|
||||||
|
*
|
||||||
|
* @param keyUUID The key UUID to test
|
||||||
|
* @param isMasterKey Whether the key is a master key
|
||||||
|
* @return true if the door can be unlocked
|
||||||
|
*/
|
||||||
|
public boolean canUnlockWith(UUID keyUUID, boolean isMasterKey) {
|
||||||
|
if (!locked) return true;
|
||||||
|
if (isMasterKey) return true;
|
||||||
|
return matchesKey(keyUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NBT SERIALIZATION ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load(CompoundTag tag) {
|
||||||
|
super.load(tag);
|
||||||
|
|
||||||
|
this.locked = tag.getBoolean(ILockable.NBT_LOCKED);
|
||||||
|
|
||||||
|
if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) {
|
||||||
|
this.lockedByKeyUUID = tag.getUUID(
|
||||||
|
ILockable.NBT_LOCKED_BY_KEY_UUID
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.lockedByKeyUUID = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void saveAdditional(CompoundTag tag) {
|
||||||
|
super.saveAdditional(tag);
|
||||||
|
|
||||||
|
tag.putBoolean(ILockable.NBT_LOCKED, locked);
|
||||||
|
|
||||||
|
if (lockedByKeyUUID != null) {
|
||||||
|
tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NETWORK SYNC ====================
|
||||||
|
|
||||||
|
protected void setChangedAndSync() {
|
||||||
|
if (level != null) {
|
||||||
|
setChanged();
|
||||||
|
level.sendBlockUpdated(
|
||||||
|
worldPosition,
|
||||||
|
getBlockState(),
|
||||||
|
getBlockState(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag getUpdateTag() {
|
||||||
|
CompoundTag tag = super.getUpdateTag();
|
||||||
|
tag.putBoolean(ILockable.NBT_LOCKED, locked);
|
||||||
|
if (lockedByKeyUUID != null) {
|
||||||
|
tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID);
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleUpdateTag(CompoundTag tag) {
|
||||||
|
this.locked = tag.getBoolean(ILockable.NBT_LOCKED);
|
||||||
|
if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) {
|
||||||
|
this.lockedByKeyUUID = tag.getUUID(
|
||||||
|
ILockable.NBT_LOCKED_BY_KEY_UUID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockEntity for kidnap bomb blocks.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Stores bondage items that will be applied when the bomb explodes.
|
||||||
|
* Simple extension of BondageItemBlockEntity.
|
||||||
|
*
|
||||||
|
* Based on original TileEntityKidnapBomb from 1.12.2
|
||||||
|
*/
|
||||||
|
public class KidnapBombBlockEntity extends BondageItemBlockEntity {
|
||||||
|
|
||||||
|
public KidnapBombBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with off-mode for tooltip reading.
|
||||||
|
*/
|
||||||
|
public KidnapBombBlockEntity(
|
||||||
|
BlockPos pos,
|
||||||
|
BlockState state,
|
||||||
|
boolean offMode
|
||||||
|
) {
|
||||||
|
super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state, offMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
1146
src/main/java/com/tiedup/remake/blocks/entity/MarkerBlockEntity.java
Normal file
1146
src/main/java/com/tiedup/remake/blocks/entity/MarkerBlockEntity.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import com.tiedup.remake.blocks.ModBlocks;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||||
|
import net.minecraftforge.registries.DeferredRegister;
|
||||||
|
import net.minecraftforge.registries.ForgeRegistries;
|
||||||
|
import net.minecraftforge.registries.RegistryObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mod Block Entities Registration
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Handles registration of all TiedUp block entities using DeferredRegister.
|
||||||
|
*/
|
||||||
|
public class ModBlockEntities {
|
||||||
|
|
||||||
|
// DeferredRegister for block entity types
|
||||||
|
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
|
||||||
|
DeferredRegister.create(
|
||||||
|
ForgeRegistries.BLOCK_ENTITY_TYPES,
|
||||||
|
TiedUpMod.MOD_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
// TRAP BLOCK ENTITIES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trap block entity - stores bondage items for rope trap.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<BlockEntityType<TrapBlockEntity>> TRAP =
|
||||||
|
BLOCK_ENTITIES.register("trap", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
TrapBlockEntity::new,
|
||||||
|
ModBlocks.ROPE_TRAP.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// LOW FIX: Removed BED BLOCK ENTITIES section - feature not implemented
|
||||||
|
|
||||||
|
// BOMB BLOCK ENTITIES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kidnap bomb block entity - stores bondage items for explosion effect.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<
|
||||||
|
BlockEntityType<KidnapBombBlockEntity>
|
||||||
|
> KIDNAP_BOMB = BLOCK_ENTITIES.register("kidnap_bomb", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
KidnapBombBlockEntity::new,
|
||||||
|
ModBlocks.KIDNAP_BOMB.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// CHEST BLOCK ENTITIES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trapped chest block entity - stores bondage items for when player opens it.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<
|
||||||
|
BlockEntityType<TrappedChestBlockEntity>
|
||||||
|
> TRAPPED_CHEST = BLOCK_ENTITIES.register("trapped_chest", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
TrappedChestBlockEntity::new,
|
||||||
|
ModBlocks.TRAPPED_CHEST.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// CELL SYSTEM BLOCK ENTITIES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker block entity - stores cell UUID for cell system.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<
|
||||||
|
BlockEntityType<MarkerBlockEntity>
|
||||||
|
> MARKER = BLOCK_ENTITIES.register("marker", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
MarkerBlockEntity::new,
|
||||||
|
ModBlocks.MARKER.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iron bar door block entity - stores lock state and key UUID.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<
|
||||||
|
BlockEntityType<IronBarDoorBlockEntity>
|
||||||
|
> IRON_BAR_DOOR = BLOCK_ENTITIES.register("iron_bar_door", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
IronBarDoorBlockEntity::new,
|
||||||
|
ModBlocks.IRON_BAR_DOOR.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell Core block entity - stores cell ID, spawn/delivery points, and disguise.
|
||||||
|
*/
|
||||||
|
public static final RegistryObject<
|
||||||
|
BlockEntityType<CellCoreBlockEntity>
|
||||||
|
> CELL_CORE = BLOCK_ENTITIES.register("cell_core", () ->
|
||||||
|
BlockEntityType.Builder.of(
|
||||||
|
CellCoreBlockEntity::new,
|
||||||
|
ModBlocks.CELL_CORE.get()
|
||||||
|
).build(null)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockEntity for rope trap blocks.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Stores bondage items that will be applied when an entity walks on the trap.
|
||||||
|
* Simple extension of BondageItemBlockEntity.
|
||||||
|
*
|
||||||
|
* Based on original TileEntityTrap from 1.12.2
|
||||||
|
*/
|
||||||
|
public class TrapBlockEntity extends BondageItemBlockEntity {
|
||||||
|
|
||||||
|
public TrapBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.TRAP.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with off-mode for tooltip reading.
|
||||||
|
*/
|
||||||
|
public TrapBlockEntity(BlockPos pos, BlockState state, boolean offMode) {
|
||||||
|
super(ModBlockEntities.TRAP.get(), pos, state, offMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.tiedup.remake.blocks.entity;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.*;
|
||||||
|
import com.tiedup.remake.items.clothes.GenericClothes;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.protocol.Packet;
|
||||||
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlockEntity for trapped chest blocks.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Extends ChestBlockEntity for proper chest behavior,
|
||||||
|
* but also stores bondage items for the trap.
|
||||||
|
*/
|
||||||
|
public class TrappedChestBlockEntity
|
||||||
|
extends ChestBlockEntity
|
||||||
|
implements IBondageItemHolder
|
||||||
|
{
|
||||||
|
|
||||||
|
// Bondage item storage (separate from chest inventory)
|
||||||
|
private ItemStack bind = ItemStack.EMPTY;
|
||||||
|
private ItemStack gag = ItemStack.EMPTY;
|
||||||
|
private ItemStack blindfold = ItemStack.EMPTY;
|
||||||
|
private ItemStack earplugs = ItemStack.EMPTY;
|
||||||
|
private ItemStack collar = ItemStack.EMPTY;
|
||||||
|
private ItemStack clothes = ItemStack.EMPTY;
|
||||||
|
|
||||||
|
public TrappedChestBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.TRAPPED_CHEST.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BONDAGE ITEM HOLDER IMPLEMENTATION
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getBind() {
|
||||||
|
return bind;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBind(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof ItemBind) {
|
||||||
|
this.bind = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getGag() {
|
||||||
|
return gag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setGag(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof ItemGag) {
|
||||||
|
this.gag = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getBlindfold() {
|
||||||
|
return blindfold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setBlindfold(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof ItemBlindfold) {
|
||||||
|
this.blindfold = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getEarplugs() {
|
||||||
|
return earplugs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setEarplugs(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof ItemEarplugs) {
|
||||||
|
this.earplugs = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getCollar() {
|
||||||
|
return collar;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCollar(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof ItemCollar) {
|
||||||
|
this.collar = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack getClothes() {
|
||||||
|
return clothes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClothes(ItemStack stack) {
|
||||||
|
if (stack.isEmpty() || stack.getItem() instanceof GenericClothes) {
|
||||||
|
this.clothes = stack;
|
||||||
|
setChangedAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isArmed() {
|
||||||
|
return (
|
||||||
|
!bind.isEmpty() ||
|
||||||
|
!gag.isEmpty() ||
|
||||||
|
!blindfold.isEmpty() ||
|
||||||
|
!earplugs.isEmpty() ||
|
||||||
|
!collar.isEmpty() ||
|
||||||
|
!clothes.isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBondageData(CompoundTag tag) {
|
||||||
|
if (tag.contains("bind")) bind = ItemStack.of(tag.getCompound("bind"));
|
||||||
|
if (tag.contains("gag")) gag = ItemStack.of(tag.getCompound("gag"));
|
||||||
|
if (tag.contains("blindfold")) blindfold = ItemStack.of(
|
||||||
|
tag.getCompound("blindfold")
|
||||||
|
);
|
||||||
|
if (tag.contains("earplugs")) earplugs = ItemStack.of(
|
||||||
|
tag.getCompound("earplugs")
|
||||||
|
);
|
||||||
|
if (tag.contains("collar")) collar = ItemStack.of(
|
||||||
|
tag.getCompound("collar")
|
||||||
|
);
|
||||||
|
if (tag.contains("clothes")) clothes = ItemStack.of(
|
||||||
|
tag.getCompound("clothes")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag writeBondageData(CompoundTag tag) {
|
||||||
|
if (!bind.isEmpty()) tag.put("bind", bind.save(new CompoundTag()));
|
||||||
|
if (!gag.isEmpty()) tag.put("gag", gag.save(new CompoundTag()));
|
||||||
|
if (!blindfold.isEmpty()) tag.put(
|
||||||
|
"blindfold",
|
||||||
|
blindfold.save(new CompoundTag())
|
||||||
|
);
|
||||||
|
if (!earplugs.isEmpty()) tag.put(
|
||||||
|
"earplugs",
|
||||||
|
earplugs.save(new CompoundTag())
|
||||||
|
);
|
||||||
|
if (!collar.isEmpty()) tag.put(
|
||||||
|
"collar",
|
||||||
|
collar.save(new CompoundTag())
|
||||||
|
);
|
||||||
|
if (!clothes.isEmpty()) tag.put(
|
||||||
|
"clothes",
|
||||||
|
clothes.save(new CompoundTag())
|
||||||
|
);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NBT SERIALIZATION
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load(CompoundTag tag) {
|
||||||
|
super.load(tag);
|
||||||
|
readBondageData(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void saveAdditional(CompoundTag tag) {
|
||||||
|
super.saveAdditional(tag);
|
||||||
|
writeBondageData(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NETWORK SYNC
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark dirty and sync to clients.
|
||||||
|
* Ensures bondage trap state is visible to all players.
|
||||||
|
*/
|
||||||
|
protected void setChangedAndSync() {
|
||||||
|
if (this.level != null) {
|
||||||
|
this.setChanged();
|
||||||
|
// Notify clients of block update
|
||||||
|
this.level.sendBlockUpdated(
|
||||||
|
this.worldPosition,
|
||||||
|
this.getBlockState(),
|
||||||
|
this.getBlockState(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag getUpdateTag() {
|
||||||
|
CompoundTag tag = super.getUpdateTag();
|
||||||
|
writeBondageData(tag);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
|
return ClientboundBlockEntityDataPacket.create(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleUpdateTag(CompoundTag tag) {
|
||||||
|
super.handleUpdateTag(tag);
|
||||||
|
readBondageData(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/main/java/com/tiedup/remake/bounty/Bounty.java
Normal file
209
src/main/java/com/tiedup/remake/bounty/Bounty.java
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package com.tiedup.remake.bounty;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single bounty placed on a player.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* A bounty is created when a player (client) offers a reward for capturing
|
||||||
|
* another player (target). Anyone who delivers the target to the client
|
||||||
|
* receives the reward.
|
||||||
|
*/
|
||||||
|
public class Bounty {
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final UUID clientId;
|
||||||
|
private final UUID targetId;
|
||||||
|
private String clientName;
|
||||||
|
private String targetName;
|
||||||
|
private final ItemStack reward;
|
||||||
|
private final long creationTime; // System.currentTimeMillis()
|
||||||
|
private final int durationSeconds; // How long the bounty lasts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bounty.
|
||||||
|
*/
|
||||||
|
public Bounty(
|
||||||
|
UUID clientId,
|
||||||
|
String clientName,
|
||||||
|
UUID targetId,
|
||||||
|
String targetName,
|
||||||
|
ItemStack reward,
|
||||||
|
int durationSeconds
|
||||||
|
) {
|
||||||
|
this.id = UUID.randomUUID().toString();
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.clientName = clientName;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.targetName = targetName;
|
||||||
|
this.reward = reward.copy();
|
||||||
|
this.creationTime = System.currentTimeMillis();
|
||||||
|
this.durationSeconds = durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a bounty from NBT data.
|
||||||
|
*/
|
||||||
|
private Bounty(
|
||||||
|
String id,
|
||||||
|
UUID clientId,
|
||||||
|
String clientName,
|
||||||
|
UUID targetId,
|
||||||
|
String targetName,
|
||||||
|
ItemStack reward,
|
||||||
|
long creationTime,
|
||||||
|
int durationSeconds
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.clientName = clientName;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.targetName = targetName;
|
||||||
|
this.reward = reward;
|
||||||
|
this.creationTime = creationTime;
|
||||||
|
this.durationSeconds = durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GETTERS ====================
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getClientId() {
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTargetId() {
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClientName() {
|
||||||
|
return clientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetName() {
|
||||||
|
return targetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ItemStack getReward() {
|
||||||
|
return reward.copy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDurationSeconds() {
|
||||||
|
return durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCreationTime() {
|
||||||
|
return creationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TIME CALCULATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining time in seconds.
|
||||||
|
*/
|
||||||
|
public int getSecondsRemaining() {
|
||||||
|
long elapsed = (System.currentTimeMillis() - creationTime) / 1000;
|
||||||
|
return Math.max(0, durationSeconds - (int) elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining time as [hours, minutes].
|
||||||
|
*/
|
||||||
|
public int[] getRemainingTime() {
|
||||||
|
int seconds = getSecondsRemaining();
|
||||||
|
int hours = seconds / 3600;
|
||||||
|
int minutes = (seconds % 3600) / 60;
|
||||||
|
return new int[] { hours, minutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if bounty has expired.
|
||||||
|
*/
|
||||||
|
public boolean isExpired() {
|
||||||
|
return getSecondsRemaining() <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PLAYER CHECKS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is the client (creator) of this bounty.
|
||||||
|
*/
|
||||||
|
public boolean isClient(UUID playerId) {
|
||||||
|
return clientId.equals(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is the target of this bounty.
|
||||||
|
*/
|
||||||
|
public boolean isTarget(UUID playerId) {
|
||||||
|
return targetId.equals(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if bounty matches (client receives target).
|
||||||
|
*/
|
||||||
|
public boolean matches(UUID clientId, UUID targetId) {
|
||||||
|
return this.clientId.equals(clientId) && this.targetId.equals(targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== REWARD DESCRIPTION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a description of the reward (e.g., "Diamond x 5").
|
||||||
|
*/
|
||||||
|
public String getRewardDescription() {
|
||||||
|
if (reward.isEmpty()) {
|
||||||
|
return "Nothing";
|
||||||
|
}
|
||||||
|
return reward.getHoverName().getString() + " x " + reward.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NAME UPDATES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update client name (for when player was offline during creation).
|
||||||
|
*/
|
||||||
|
public void setClientName(String name) {
|
||||||
|
this.clientName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update target name (for when player was offline during creation).
|
||||||
|
*/
|
||||||
|
public void setTargetName(String name) {
|
||||||
|
this.targetName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NBT SERIALIZATION ====================
|
||||||
|
|
||||||
|
public CompoundTag save() {
|
||||||
|
CompoundTag tag = new CompoundTag();
|
||||||
|
tag.putString("id", id);
|
||||||
|
tag.putUUID("clientId", clientId);
|
||||||
|
tag.putUUID("targetId", targetId);
|
||||||
|
tag.putString("clientName", clientName);
|
||||||
|
tag.putString("targetName", targetName);
|
||||||
|
tag.put("reward", reward.save(new CompoundTag()));
|
||||||
|
tag.putLong("creationTime", creationTime);
|
||||||
|
tag.putInt("durationSeconds", durationSeconds);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Bounty load(CompoundTag tag) {
|
||||||
|
return new Bounty(
|
||||||
|
tag.getString("id"),
|
||||||
|
tag.getUUID("clientId"),
|
||||||
|
tag.getString("clientName"),
|
||||||
|
tag.getUUID("targetId"),
|
||||||
|
tag.getString("targetName"),
|
||||||
|
ItemStack.of(tag.getCompound("reward")),
|
||||||
|
tag.getLong("creationTime"),
|
||||||
|
tag.getInt("durationSeconds")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/main/java/com/tiedup/remake/bounty/BountyManager.java
Normal file
406
src/main/java/com/tiedup/remake/bounty/BountyManager.java
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package com.tiedup.remake.bounty;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.SettingsAccessor;
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.*;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World-saved data manager for bounties.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Manages all active bounties, handles expiration, delivery rewards,
|
||||||
|
* and stores bounties for offline players.
|
||||||
|
*/
|
||||||
|
public class BountyManager extends SavedData {
|
||||||
|
|
||||||
|
private static final String DATA_NAME = "tiedup_bounties";
|
||||||
|
|
||||||
|
/** Pending rewards expire after 30 real days (in milliseconds). */
|
||||||
|
private static final long PENDING_REWARD_EXPIRATION_MS =
|
||||||
|
30L * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Active bounties
|
||||||
|
private final List<Bounty> bounties = new ArrayList<>();
|
||||||
|
|
||||||
|
// Bounties for offline players (to return reward when they log in)
|
||||||
|
// Stored with timestamp via Bounty.creationTime + durationSeconds
|
||||||
|
private final List<Bounty> pendingRewards = new ArrayList<>();
|
||||||
|
|
||||||
|
// ==================== CONSTRUCTION ====================
|
||||||
|
|
||||||
|
public BountyManager() {}
|
||||||
|
|
||||||
|
public static BountyManager create() {
|
||||||
|
return new BountyManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BountyManager load(CompoundTag tag) {
|
||||||
|
BountyManager manager = new BountyManager();
|
||||||
|
|
||||||
|
// Load active bounties
|
||||||
|
ListTag bountiesTag = tag.getList("bounties", Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < bountiesTag.size(); i++) {
|
||||||
|
manager.bounties.add(Bounty.load(bountiesTag.getCompound(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load pending rewards (with expiration cleanup)
|
||||||
|
ListTag pendingTag = tag.getList("pendingRewards", Tag.TAG_COMPOUND);
|
||||||
|
int expiredCount = 0;
|
||||||
|
for (int i = 0; i < pendingTag.size(); i++) {
|
||||||
|
Bounty bounty = Bounty.load(pendingTag.getCompound(i));
|
||||||
|
if (!isPendingRewardExpired(bounty)) {
|
||||||
|
manager.pendingRewards.add(bounty);
|
||||||
|
} else {
|
||||||
|
expiredCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredCount > 0) {
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BOUNTY] Cleaned up {} expired pending rewards (>30 days)",
|
||||||
|
expiredCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BOUNTY] Loaded {} active bounties, {} pending rewards",
|
||||||
|
manager.bounties.size(),
|
||||||
|
manager.pendingRewards.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag save(CompoundTag tag) {
|
||||||
|
// Save active bounties
|
||||||
|
ListTag bountiesTag = new ListTag();
|
||||||
|
for (Bounty bounty : bounties) {
|
||||||
|
bountiesTag.add(bounty.save());
|
||||||
|
}
|
||||||
|
tag.put("bounties", bountiesTag);
|
||||||
|
|
||||||
|
// Save pending rewards
|
||||||
|
ListTag pendingTag = new ListTag();
|
||||||
|
for (Bounty bounty : pendingRewards) {
|
||||||
|
pendingTag.add(bounty.save());
|
||||||
|
}
|
||||||
|
tag.put("pendingRewards", pendingTag);
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ACCESS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the BountyManager for a world.
|
||||||
|
*/
|
||||||
|
public static BountyManager get(ServerLevel level) {
|
||||||
|
return level
|
||||||
|
.getDataStorage()
|
||||||
|
.computeIfAbsent(
|
||||||
|
BountyManager::load,
|
||||||
|
BountyManager::create,
|
||||||
|
DATA_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the BountyManager from a server.
|
||||||
|
*/
|
||||||
|
public static BountyManager get(MinecraftServer server) {
|
||||||
|
ServerLevel overworld = server.overworld();
|
||||||
|
return get(overworld);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BOUNTY MANAGEMENT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active bounties (removes expired ones).
|
||||||
|
*/
|
||||||
|
public List<Bounty> getBounties(ServerLevel level) {
|
||||||
|
// Clean up expired bounties
|
||||||
|
Iterator<Bounty> it = bounties.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Bounty bounty = it.next();
|
||||||
|
if (bounty.isExpired()) {
|
||||||
|
it.remove();
|
||||||
|
onBountyExpired(level, bounty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDirty();
|
||||||
|
return new ArrayList<>(bounties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new bounty.
|
||||||
|
*/
|
||||||
|
public void addBounty(Bounty bounty) {
|
||||||
|
bounties.add(bounty);
|
||||||
|
setDirty();
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BOUNTY] New bounty: {} on {} by {}",
|
||||||
|
bounty.getId(),
|
||||||
|
bounty.getTargetName(),
|
||||||
|
bounty.getClientName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a bounty by ID.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public Bounty getBountyById(String id) {
|
||||||
|
for (Bounty bounty : bounties) {
|
||||||
|
if (bounty.getId().equals(id)) {
|
||||||
|
return bounty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a bounty.
|
||||||
|
* Only the client or an admin can cancel.
|
||||||
|
* If client cancels, they get their reward back.
|
||||||
|
*/
|
||||||
|
public boolean cancelBounty(ServerPlayer player, String bountyId) {
|
||||||
|
Bounty bounty = getBountyById(bountyId);
|
||||||
|
if (bounty == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isAdmin = player.hasPermissions(2);
|
||||||
|
boolean isClient = bounty.isClient(player.getUUID());
|
||||||
|
|
||||||
|
if (!isClient && !isAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bounties.remove(bounty);
|
||||||
|
setDirty();
|
||||||
|
|
||||||
|
// Return reward to client (or drop if admin cancelled)
|
||||||
|
if (isClient) {
|
||||||
|
giveReward(player, bounty);
|
||||||
|
broadcastMessage(
|
||||||
|
player.server,
|
||||||
|
player.getName().getString() +
|
||||||
|
" cancelled their bounty on " +
|
||||||
|
bounty.getTargetName()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onBountyExpired(player.serverLevel(), bounty);
|
||||||
|
broadcastMessage(
|
||||||
|
player.server,
|
||||||
|
player.getName().getString() +
|
||||||
|
" (admin) cancelled bounty on " +
|
||||||
|
bounty.getTargetName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELIVERY ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to deliver a captive to a client.
|
||||||
|
* Called when a hunter brings a captive near the bounty client.
|
||||||
|
*
|
||||||
|
* @param hunter The player delivering the captive
|
||||||
|
* @param client The bounty client receiving the captive
|
||||||
|
* @param target The captive being delivered
|
||||||
|
* @return true if bounty was fulfilled
|
||||||
|
*/
|
||||||
|
public boolean tryDeliverCaptive(
|
||||||
|
ServerPlayer hunter,
|
||||||
|
ServerPlayer client,
|
||||||
|
ServerPlayer target
|
||||||
|
) {
|
||||||
|
boolean delivered = false;
|
||||||
|
|
||||||
|
Iterator<Bounty> it = bounties.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Bounty bounty = it.next();
|
||||||
|
|
||||||
|
// Skip expired
|
||||||
|
if (bounty.isExpired()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this bounty matches
|
||||||
|
if (bounty.matches(client.getUUID(), target.getUUID())) {
|
||||||
|
it.remove();
|
||||||
|
setDirty();
|
||||||
|
|
||||||
|
// Give reward to hunter
|
||||||
|
giveReward(hunter, bounty);
|
||||||
|
delivered = true;
|
||||||
|
|
||||||
|
broadcastMessage(
|
||||||
|
hunter.server,
|
||||||
|
hunter.getName().getString() +
|
||||||
|
" delivered " +
|
||||||
|
target.getName().getString() +
|
||||||
|
" to " +
|
||||||
|
client.getName().getString() +
|
||||||
|
" for " +
|
||||||
|
bounty.getRewardDescription() +
|
||||||
|
"!"
|
||||||
|
);
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BOUNTY] Delivered: {} brought {} to {}",
|
||||||
|
hunter.getName().getString(),
|
||||||
|
target.getName().getString(),
|
||||||
|
client.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXPIRATION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle bounty expiration.
|
||||||
|
* Returns reward to client if online, otherwise stores for later.
|
||||||
|
*/
|
||||||
|
private void onBountyExpired(ServerLevel level, Bounty bounty) {
|
||||||
|
ServerPlayer client = level
|
||||||
|
.getServer()
|
||||||
|
.getPlayerList()
|
||||||
|
.getPlayer(bounty.getClientId());
|
||||||
|
|
||||||
|
if (client != null) {
|
||||||
|
// Client is online - return reward
|
||||||
|
giveReward(client, bounty);
|
||||||
|
SystemMessageManager.sendChatToPlayer(
|
||||||
|
client,
|
||||||
|
"Your bounty on " +
|
||||||
|
bounty.getTargetName() +
|
||||||
|
" has expired. Reward returned.",
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Client is offline - store for later
|
||||||
|
pendingRewards.add(bounty);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BOUNTY] Expired: {} on {}",
|
||||||
|
bounty.getClientName(),
|
||||||
|
bounty.getTargetName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for pending rewards when a player joins.
|
||||||
|
*/
|
||||||
|
public void onPlayerJoin(ServerPlayer player) {
|
||||||
|
Iterator<Bounty> it = pendingRewards.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Bounty bounty = it.next();
|
||||||
|
if (bounty.isClient(player.getUUID())) {
|
||||||
|
giveReward(player, bounty);
|
||||||
|
it.remove();
|
||||||
|
setDirty();
|
||||||
|
|
||||||
|
SystemMessageManager.sendChatToPlayer(
|
||||||
|
player,
|
||||||
|
"Your expired bounty reward has been returned: " +
|
||||||
|
bounty.getRewardDescription(),
|
||||||
|
ChatFormatting.YELLOW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VALIDATION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a player can create a new bounty.
|
||||||
|
*/
|
||||||
|
public boolean canCreateBounty(ServerPlayer player, ServerLevel level) {
|
||||||
|
if (player.hasPermissions(2)) {
|
||||||
|
return true; // Admins bypass limit
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (Bounty bounty : bounties) {
|
||||||
|
if (bounty.isClient(player.getUUID())) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = SettingsAccessor.getMaxBounties(level.getGameRules());
|
||||||
|
return count < max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of active bounties for a player.
|
||||||
|
*/
|
||||||
|
public int getBountyCount(UUID playerId) {
|
||||||
|
int count = 0;
|
||||||
|
for (Bounty bounty : bounties) {
|
||||||
|
if (bounty.isClient(playerId)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HELPERS ====================
|
||||||
|
|
||||||
|
private void giveReward(ServerPlayer player, Bounty bounty) {
|
||||||
|
ItemStack reward = bounty.getReward();
|
||||||
|
if (!reward.isEmpty()) {
|
||||||
|
if (!player.getInventory().add(reward)) {
|
||||||
|
// Inventory full - drop at feet
|
||||||
|
player.drop(reward, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcastMessage(MinecraftServer server, String message) {
|
||||||
|
server
|
||||||
|
.getPlayerList()
|
||||||
|
.broadcastSystemMessage(
|
||||||
|
Component.literal("[Bounty] " + message).withStyle(
|
||||||
|
ChatFormatting.GOLD
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pending reward has been waiting too long (>30 days).
|
||||||
|
* Uses the bounty's original expiration time as baseline.
|
||||||
|
*/
|
||||||
|
private static boolean isPendingRewardExpired(Bounty bounty) {
|
||||||
|
// Calculate when the bounty originally expired
|
||||||
|
// creationTime is in milliseconds, durationSeconds needs conversion
|
||||||
|
long expirationTime =
|
||||||
|
bounty.getCreationTime() + (bounty.getDurationSeconds() * 1000L);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// Check if it's been more than 30 days since expiration
|
||||||
|
return (now - expirationTime) > PENDING_REWARD_EXPIRATION_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java
Normal file
332
src/main/java/com/tiedup/remake/cells/CampLifecycleManager.java
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.ILockable;
|
||||||
|
import com.tiedup.remake.items.base.ItemCollar;
|
||||||
|
import com.tiedup.remake.prison.PrisonerManager;
|
||||||
|
import com.tiedup.remake.state.IRestrainable;
|
||||||
|
import com.tiedup.remake.util.KidnappedHelper;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles camp lifecycle events: camp death, prisoner freeing, and camp defense alerts.
|
||||||
|
*
|
||||||
|
* <p>This is a stateless utility class. All state lives in {@link CampOwnership}
|
||||||
|
* (the SavedData singleton). Methods here orchestrate multi-system side effects
|
||||||
|
* that don't belong in the data layer.
|
||||||
|
*/
|
||||||
|
public final class CampLifecycleManager {
|
||||||
|
|
||||||
|
private CampLifecycleManager() {} // utility class
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a camp as dead (trader killed) and perform full cleanup.
|
||||||
|
* This:
|
||||||
|
* - Cancels all ransoms for the camp's prisoners
|
||||||
|
* - Frees all prisoners (untie, unlock collars)
|
||||||
|
* - Clears all labor states
|
||||||
|
* - Removes all cells belonging to the camp
|
||||||
|
* - Makes the camp inactive
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param level The server level for entity lookups
|
||||||
|
*/
|
||||||
|
public static void markCampDead(UUID campId, ServerLevel level) {
|
||||||
|
CampOwnership ownership = CampOwnership.get(level);
|
||||||
|
CampOwnership.CampData data = ownership.getCamp(campId);
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID traderUUID = data.getTraderUUID();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampLifecycleManager] Camp {} dying - freeing all prisoners",
|
||||||
|
campId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
// PERFORMANCE FIX: Use PrisonerManager's index instead of CellRegistry
|
||||||
|
// This is O(1) lookup instead of iterating all cells
|
||||||
|
PrisonerManager manager = PrisonerManager.get(level);
|
||||||
|
Set<UUID> prisonerIds = manager.getPrisonersInCamp(campId);
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CampLifecycleManager] Found {} prisoners in camp {} via index",
|
||||||
|
prisonerIds.size(),
|
||||||
|
campId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel ransoms and free each prisoner
|
||||||
|
for (UUID prisonerId : prisonerIds) {
|
||||||
|
// Cancel ransom by clearing it
|
||||||
|
if (manager.getRansomRecord(prisonerId) != null) {
|
||||||
|
manager.setRansomRecord(prisonerId, null);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CampLifecycleManager] Cancelled ransom for prisoner {}",
|
||||||
|
prisonerId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the prisoner
|
||||||
|
ServerPlayer prisoner = level
|
||||||
|
.getServer()
|
||||||
|
.getPlayerList()
|
||||||
|
.getPlayer(prisonerId);
|
||||||
|
if (prisoner != null) {
|
||||||
|
// Online: untie, unlock collar, release with 5-min grace period
|
||||||
|
removeLaborTools(prisoner);
|
||||||
|
freePrisonerOnCampDeath(prisoner, traderUUID, level);
|
||||||
|
// Cell cleanup only -- freePrisonerOnCampDeath already called release()
|
||||||
|
// which transitions to PROTECTED with grace period.
|
||||||
|
// Calling escape() here would override PROTECTED->FREE, losing the grace.
|
||||||
|
CellRegistryV2.get(level).releasePrisonerFromAllCells(
|
||||||
|
prisonerId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Offline: full escape via PrisonerService (no grace period needed)
|
||||||
|
com.tiedup.remake.prison.service.PrisonerService.get().escape(
|
||||||
|
level,
|
||||||
|
prisonerId,
|
||||||
|
"camp death"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ownership.unmarkPrisonerProcessed(prisonerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HIGH FIX: Remove all cells belonging to this camp from CellRegistryV2
|
||||||
|
// Prevents memory leak and stale data in indices
|
||||||
|
CellRegistryV2 cellRegistry = CellRegistryV2.get(level);
|
||||||
|
List<CellDataV2> campCells = cellRegistry.getCellsByCamp(campId);
|
||||||
|
for (CellDataV2 cell : campCells) {
|
||||||
|
cellRegistry.removeCell(cell.getId());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CampLifecycleManager] Removed cell {} from registry",
|
||||||
|
cell.getId().toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampLifecycleManager] Removed {} cells for dead camp {}",
|
||||||
|
campCells.size(),
|
||||||
|
campId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark camp as dead
|
||||||
|
data.setAlive(false);
|
||||||
|
ownership.setDirty();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampLifecycleManager] Camp {} is now dead, {} prisoners freed",
|
||||||
|
campId.toString().substring(0, 8),
|
||||||
|
prisonerIds.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert all NPCs in a camp to defend against an attacker.
|
||||||
|
* Called when someone tries to restrain the trader or maid.
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param attacker The player attacking
|
||||||
|
* @param level The server level
|
||||||
|
*/
|
||||||
|
public static void alertCampToDefend(
|
||||||
|
UUID campId,
|
||||||
|
Player attacker,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
CampOwnership ownership = CampOwnership.get(level);
|
||||||
|
CampOwnership.CampData camp = ownership.getCamp(campId);
|
||||||
|
if (camp == null) return;
|
||||||
|
|
||||||
|
int alertedCount = 0;
|
||||||
|
|
||||||
|
// 1. Alert the trader
|
||||||
|
UUID traderUUID = camp.getTraderUUID();
|
||||||
|
if (traderUUID != null) {
|
||||||
|
net.minecraft.world.entity.Entity traderEntity = level.getEntity(
|
||||||
|
traderUUID
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
traderEntity instanceof
|
||||||
|
com.tiedup.remake.entities.EntitySlaveTrader trader
|
||||||
|
) {
|
||||||
|
if (trader.isAlive() && !trader.isTiedUp()) {
|
||||||
|
trader.setTarget(attacker);
|
||||||
|
trader.setLastAttacker(attacker);
|
||||||
|
alertedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Alert the maid
|
||||||
|
UUID maidUUID = camp.getMaidUUID();
|
||||||
|
if (maidUUID != null) {
|
||||||
|
net.minecraft.world.entity.Entity maidEntity = level.getEntity(
|
||||||
|
maidUUID
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
maidEntity instanceof com.tiedup.remake.entities.EntityMaid maid
|
||||||
|
) {
|
||||||
|
if (maid.isAlive() && !maid.isTiedUp()) {
|
||||||
|
maid.setTarget(attacker);
|
||||||
|
maid.setMaidState(
|
||||||
|
com.tiedup.remake.entities.ai.maid.MaidState.DEFENDING
|
||||||
|
);
|
||||||
|
alertedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Alert all kidnappers
|
||||||
|
Set<UUID> kidnapperUUIDs = camp.getLinkedKidnappers();
|
||||||
|
for (UUID kidnapperUUID : kidnapperUUIDs) {
|
||||||
|
net.minecraft.world.entity.Entity entity = level.getEntity(
|
||||||
|
kidnapperUUID
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
entity instanceof
|
||||||
|
com.tiedup.remake.entities.EntityKidnapper kidnapper
|
||||||
|
) {
|
||||||
|
if (kidnapper.isAlive() && !kidnapper.isTiedUp()) {
|
||||||
|
kidnapper.setTarget(attacker);
|
||||||
|
kidnapper.setLastAttacker(attacker);
|
||||||
|
alertedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampLifecycleManager] Camp {} alerted {} NPCs to defend against {}",
|
||||||
|
campId.toString().substring(0, 8),
|
||||||
|
alertedCount,
|
||||||
|
attacker.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PRIVATE HELPERS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free a prisoner when their camp dies.
|
||||||
|
* Untie, unlock collar, cancel sale, notify.
|
||||||
|
* Uses legitimate removal flag to prevent alerting kidnappers.
|
||||||
|
*/
|
||||||
|
private static void freePrisonerOnCampDeath(
|
||||||
|
ServerPlayer prisoner,
|
||||||
|
UUID traderUUID,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
IRestrainable state = KidnappedHelper.getKidnappedState(prisoner);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress collar removal alerts - this is a legitimate release (camp death)
|
||||||
|
ItemCollar.runWithSuppressedAlert(() -> {
|
||||||
|
// Unlock collar if owned by the dead camp/trader
|
||||||
|
unlockCollarIfOwnedBy(prisoner, state, traderUUID);
|
||||||
|
|
||||||
|
// Remove all restraints (including collar if any)
|
||||||
|
state.untie(true);
|
||||||
|
|
||||||
|
// Cancel sale
|
||||||
|
state.cancelSale();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear client HUD
|
||||||
|
com.tiedup.remake.network.ModNetwork.sendToPlayer(
|
||||||
|
new com.tiedup.remake.network.labor.PacketSyncLaborProgress(),
|
||||||
|
prisoner
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify prisoner
|
||||||
|
prisoner.sendSystemMessage(
|
||||||
|
Component.literal("Your captor has died. You are FREE!").withStyle(
|
||||||
|
ChatFormatting.GREEN,
|
||||||
|
ChatFormatting.BOLD
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant grace period (5 minutes = 6000 ticks)
|
||||||
|
PrisonerManager manager = PrisonerManager.get(level);
|
||||||
|
manager.release(prisoner.getUUID(), level.getGameTime(), 6000);
|
||||||
|
|
||||||
|
prisoner.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"You have 5 minutes of protection from kidnappers."
|
||||||
|
).withStyle(ChatFormatting.AQUA)
|
||||||
|
);
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampLifecycleManager] Freed prisoner {} on camp death (no alert)",
|
||||||
|
prisoner.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock a prisoner's collar if it's owned by the specified owner (trader/kidnapper).
|
||||||
|
*/
|
||||||
|
private static void unlockCollarIfOwnedBy(
|
||||||
|
ServerPlayer prisoner,
|
||||||
|
IRestrainable state,
|
||||||
|
UUID ownerUUID
|
||||||
|
) {
|
||||||
|
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
|
||||||
|
if (collar.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||||
|
List<UUID> owners = collarItem.getOwners(collar);
|
||||||
|
|
||||||
|
// If the dead trader/camp is an owner, unlock the collar
|
||||||
|
if (owners.contains(ownerUUID)) {
|
||||||
|
if (collar.getItem() instanceof ILockable lockable) {
|
||||||
|
lockable.setLockedByKeyUUID(collar, null); // Unlock and clear
|
||||||
|
}
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CampLifecycleManager] Unlocked collar for {} (owner {} died)",
|
||||||
|
prisoner.getName().getString(),
|
||||||
|
ownerUUID.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Remove all labor tools from player inventory.
|
||||||
|
* Prevents prisoners from keeping unbreakable tools when freed/released.
|
||||||
|
*/
|
||||||
|
private static void removeLaborTools(ServerPlayer player) {
|
||||||
|
var inventory = player.getInventory();
|
||||||
|
int removedCount = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < inventory.getContainerSize(); i++) {
|
||||||
|
ItemStack stack = inventory.getItem(i);
|
||||||
|
if (!stack.isEmpty() && stack.hasTag()) {
|
||||||
|
CompoundTag tag = stack.getTag();
|
||||||
|
if (tag != null && tag.getBoolean("LaborTool")) {
|
||||||
|
inventory.setItem(i, ItemStack.EMPTY);
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedCount > 0) {
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CampLifecycleManager] Removed {} labor tools from {} on camp death",
|
||||||
|
removedCount,
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/main/java/com/tiedup/remake/cells/CampMaidManager.java
Normal file
165
src/main/java/com/tiedup/remake/cells/CampMaidManager.java
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.prison.PrisonerManager;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages maid lifecycle within camps: death, respawn timers, prisoner reassignment.
|
||||||
|
*
|
||||||
|
* <p>This is a stateless utility class. All state lives in {@link CampOwnership}
|
||||||
|
* (the SavedData singleton). Methods here orchestrate maid-specific side effects.
|
||||||
|
*/
|
||||||
|
public final class CampMaidManager {
|
||||||
|
|
||||||
|
private CampMaidManager() {} // utility class
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the maid as dead for a camp.
|
||||||
|
* The camp remains alive but prisoners are paused until new maid spawns.
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param currentTime The current game time
|
||||||
|
* @param level The server level
|
||||||
|
*/
|
||||||
|
public static void markMaidDead(
|
||||||
|
UUID campId,
|
||||||
|
long currentTime,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
CampOwnership ownership = CampOwnership.get(level);
|
||||||
|
CampOwnership.CampData data = ownership.getCamp(campId);
|
||||||
|
if (data == null || !data.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save maid UUID before clearing (fix NPE)
|
||||||
|
UUID deadMaidId = data.getMaidUUID();
|
||||||
|
|
||||||
|
data.setMaidDeathTime(currentTime);
|
||||||
|
data.setMaidUUID(null);
|
||||||
|
|
||||||
|
// Reset prisoners who were being escorted by the dead maid
|
||||||
|
if (deadMaidId != null) {
|
||||||
|
reassignPrisonersFromMaid(deadMaidId, null, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership.setDirty();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampMaidManager] Maid died for camp {} - respawn available in 5 minutes",
|
||||||
|
campId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a new maid to a camp (after respawn or initial setup).
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param newMaidUUID The new maid's UUID
|
||||||
|
* @param level The server level
|
||||||
|
*/
|
||||||
|
public static void assignNewMaid(
|
||||||
|
UUID campId,
|
||||||
|
UUID newMaidUUID,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
CampOwnership ownership = CampOwnership.get(level);
|
||||||
|
CampOwnership.CampData data = ownership.getCamp(campId);
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID oldMaidId = data.getMaidUUID();
|
||||||
|
data.setMaidUUID(newMaidUUID);
|
||||||
|
data.setMaidDeathTime(-1); // Reset death time
|
||||||
|
|
||||||
|
// Transfer prisoners to new maid
|
||||||
|
reassignPrisonersFromMaid(oldMaidId, newMaidUUID, level);
|
||||||
|
|
||||||
|
ownership.setDirty();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampMaidManager] New maid {} assigned to camp {}",
|
||||||
|
newMaidUUID.toString().substring(0, 8),
|
||||||
|
campId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camps that need a new maid spawned.
|
||||||
|
*
|
||||||
|
* @param currentTime The current game time
|
||||||
|
* @param level The server level
|
||||||
|
* @return List of camp IDs ready for maid respawn
|
||||||
|
*/
|
||||||
|
public static List<UUID> getCampsNeedingMaidRespawn(
|
||||||
|
long currentTime,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
CampOwnership ownership = CampOwnership.get(level);
|
||||||
|
List<UUID> result = new ArrayList<>();
|
||||||
|
for (CampOwnership.CampData data : ownership.getAllCamps()) {
|
||||||
|
if (data.isAlive() && data.canRespawnMaid(currentTime)) {
|
||||||
|
result.add(data.getCampId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign all prisoners from one maid to another (for maid death/replacement).
|
||||||
|
* The new PrisonerManager tracks labor state separately via LaborRecord.
|
||||||
|
*
|
||||||
|
* @param oldMaidId The old maid's UUID (or null to assign to all unassigned prisoners)
|
||||||
|
* @param newMaidId The new maid's UUID (or null if maid died with no replacement)
|
||||||
|
* @param level The server level
|
||||||
|
*/
|
||||||
|
public static void reassignPrisonersFromMaid(
|
||||||
|
@Nullable UUID oldMaidId,
|
||||||
|
@Nullable UUID newMaidId,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
PrisonerManager manager = PrisonerManager.get(level);
|
||||||
|
|
||||||
|
for (UUID playerId : manager.getAllPrisonerIds()) {
|
||||||
|
com.tiedup.remake.prison.LaborRecord labor = manager.getLaborRecord(
|
||||||
|
playerId
|
||||||
|
);
|
||||||
|
if (labor == null) continue;
|
||||||
|
|
||||||
|
// Check if this prisoner was managed by the old maid
|
||||||
|
UUID assignedMaid = labor.getMaidId();
|
||||||
|
boolean shouldReassign =
|
||||||
|
(oldMaidId == null && assignedMaid == null) ||
|
||||||
|
(oldMaidId != null && oldMaidId.equals(assignedMaid));
|
||||||
|
|
||||||
|
if (shouldReassign) {
|
||||||
|
// Update maid ID in labor record
|
||||||
|
labor.setMaidId(newMaidId);
|
||||||
|
|
||||||
|
// If maid died (no replacement) during active work, prisoner can rest
|
||||||
|
if (
|
||||||
|
newMaidId == null &&
|
||||||
|
labor.getPhase() !=
|
||||||
|
com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE
|
||||||
|
) {
|
||||||
|
labor.setPhase(
|
||||||
|
com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE,
|
||||||
|
level.getGameTime()
|
||||||
|
);
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CampMaidManager] Prisoner {} labor reset to IDLE - maid died during escort",
|
||||||
|
playerId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If there's a replacement maid, keep current state so they get picked up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CampOwnership.get(level).setDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
575
src/main/java/com/tiedup/remake/cells/CampOwnership.java
Normal file
575
src/main/java/com/tiedup/remake/cells/CampOwnership.java
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global registry for camp ownership linking camps to their SlaveTrader.
|
||||||
|
*
|
||||||
|
* This registry tracks:
|
||||||
|
* - Camp UUID -> CampData (trader, maid, alive status)
|
||||||
|
* - When a trader dies, the camp becomes inactive
|
||||||
|
*
|
||||||
|
* Persists across server restarts using Minecraft's SavedData system.
|
||||||
|
*/
|
||||||
|
public class CampOwnership extends SavedData {
|
||||||
|
|
||||||
|
private static final String DATA_NAME = "tiedup_camp_ownership";
|
||||||
|
|
||||||
|
// Camp UUID -> CampData
|
||||||
|
private final Map<UUID, CampData> camps = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Prisoners that have been processed (to avoid re-processing)
|
||||||
|
private final Set<UUID> processedPrisoners = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
// ==================== CAMP DATA CLASS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data structure representing a camp and its owner.
|
||||||
|
* Uses thread-safe collections for concurrent access.
|
||||||
|
*/
|
||||||
|
/** Maid respawn delay in ticks (5 minutes = 6000 ticks) */
|
||||||
|
public static final long MAID_RESPAWN_DELAY = 6000;
|
||||||
|
|
||||||
|
public static class CampData {
|
||||||
|
|
||||||
|
private final UUID campId;
|
||||||
|
private volatile UUID traderUUID;
|
||||||
|
private volatile UUID maidUUID;
|
||||||
|
private volatile boolean isAlive = true;
|
||||||
|
private volatile BlockPos center;
|
||||||
|
private final Set<UUID> linkedKidnapperUUIDs =
|
||||||
|
ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
/** Time when maid died (for respawn timer), -1 if alive */
|
||||||
|
private volatile long maidDeathTime = -1;
|
||||||
|
|
||||||
|
/** Cached positions of LOOT chests (below LOOT markers) */
|
||||||
|
private final List<BlockPos> lootChestPositions = new ArrayList<>();
|
||||||
|
|
||||||
|
public CampData(UUID campId) {
|
||||||
|
this.campId = campId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CampData(
|
||||||
|
UUID campId,
|
||||||
|
UUID traderUUID,
|
||||||
|
@Nullable UUID maidUUID,
|
||||||
|
BlockPos center
|
||||||
|
) {
|
||||||
|
this.campId = campId;
|
||||||
|
this.traderUUID = traderUUID;
|
||||||
|
this.maidUUID = maidUUID;
|
||||||
|
this.center = center;
|
||||||
|
this.isAlive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public UUID getCampId() {
|
||||||
|
return campId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTraderUUID() {
|
||||||
|
return traderUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getMaidUUID() {
|
||||||
|
return maidUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlive() {
|
||||||
|
return isAlive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BlockPos getCenter() {
|
||||||
|
return center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setters
|
||||||
|
public void setTraderUUID(UUID traderUUID) {
|
||||||
|
this.traderUUID = traderUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaidUUID(UUID maidUUID) {
|
||||||
|
this.maidUUID = maidUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAlive(boolean alive) {
|
||||||
|
this.isAlive = alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCenter(BlockPos center) {
|
||||||
|
this.center = center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maid death/respawn
|
||||||
|
public long getMaidDeathTime() {
|
||||||
|
return maidDeathTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaidDeathTime(long time) {
|
||||||
|
this.maidDeathTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMaidDead() {
|
||||||
|
return maidDeathTime >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canRespawnMaid(long currentTime) {
|
||||||
|
return (
|
||||||
|
isMaidDead() &&
|
||||||
|
(currentTime - maidDeathTime) >= MAID_RESPAWN_DELAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loot chest management
|
||||||
|
public List<BlockPos> getLootChestPositions() {
|
||||||
|
return lootChestPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addLootChestPosition(BlockPos pos) {
|
||||||
|
if (!lootChestPositions.contains(pos)) {
|
||||||
|
lootChestPositions.add(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kidnapper management
|
||||||
|
public void addKidnapper(UUID kidnapperUUID) {
|
||||||
|
linkedKidnapperUUIDs.add(kidnapperUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeKidnapper(UUID kidnapperUUID) {
|
||||||
|
linkedKidnapperUUIDs.remove(kidnapperUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<UUID> getLinkedKidnappers() {
|
||||||
|
return Collections.unmodifiableSet(linkedKidnapperUUIDs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasKidnapper(UUID kidnapperUUID) {
|
||||||
|
return linkedKidnapperUUIDs.contains(kidnapperUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getKidnapperCount() {
|
||||||
|
return linkedKidnapperUUIDs.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NBT Serialization
|
||||||
|
public CompoundTag save() {
|
||||||
|
CompoundTag tag = new CompoundTag();
|
||||||
|
tag.putUUID("campId", campId);
|
||||||
|
if (traderUUID != null) tag.putUUID("traderUUID", traderUUID);
|
||||||
|
if (maidUUID != null) tag.putUUID("maidUUID", maidUUID);
|
||||||
|
tag.putBoolean("isAlive", isAlive);
|
||||||
|
if (center != null) {
|
||||||
|
tag.putInt("centerX", center.getX());
|
||||||
|
tag.putInt("centerY", center.getY());
|
||||||
|
tag.putInt("centerZ", center.getZ());
|
||||||
|
}
|
||||||
|
// Save linked kidnappers
|
||||||
|
if (!linkedKidnapperUUIDs.isEmpty()) {
|
||||||
|
ListTag kidnapperList = new ListTag();
|
||||||
|
for (UUID uuid : linkedKidnapperUUIDs) {
|
||||||
|
CompoundTag uuidTag = new CompoundTag();
|
||||||
|
uuidTag.putUUID("uuid", uuid);
|
||||||
|
kidnapperList.add(uuidTag);
|
||||||
|
}
|
||||||
|
tag.put("linkedKidnappers", kidnapperList);
|
||||||
|
}
|
||||||
|
// Save maid death time
|
||||||
|
tag.putLong("maidDeathTime", maidDeathTime);
|
||||||
|
// Save loot chest positions
|
||||||
|
if (!lootChestPositions.isEmpty()) {
|
||||||
|
ListTag lootList = new ListTag();
|
||||||
|
for (BlockPos pos : lootChestPositions) {
|
||||||
|
CompoundTag posTag = new CompoundTag();
|
||||||
|
posTag.putInt("x", pos.getX());
|
||||||
|
posTag.putInt("y", pos.getY());
|
||||||
|
posTag.putInt("z", pos.getZ());
|
||||||
|
lootList.add(posTag);
|
||||||
|
}
|
||||||
|
tag.put("lootChestPositions", lootList);
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CampData load(CompoundTag tag) {
|
||||||
|
UUID campId = tag.getUUID("campId");
|
||||||
|
CampData data = new CampData(campId);
|
||||||
|
if (tag.contains("traderUUID")) data.traderUUID = tag.getUUID(
|
||||||
|
"traderUUID"
|
||||||
|
);
|
||||||
|
if (tag.contains("maidUUID")) data.maidUUID = tag.getUUID(
|
||||||
|
"maidUUID"
|
||||||
|
);
|
||||||
|
data.isAlive = tag.getBoolean("isAlive");
|
||||||
|
if (tag.contains("centerX")) {
|
||||||
|
data.center = new BlockPos(
|
||||||
|
tag.getInt("centerX"),
|
||||||
|
tag.getInt("centerY"),
|
||||||
|
tag.getInt("centerZ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Load linked kidnappers
|
||||||
|
if (tag.contains("linkedKidnappers")) {
|
||||||
|
ListTag kidnapperList = tag.getList(
|
||||||
|
"linkedKidnappers",
|
||||||
|
Tag.TAG_COMPOUND
|
||||||
|
);
|
||||||
|
for (int i = 0; i < kidnapperList.size(); i++) {
|
||||||
|
CompoundTag uuidTag = kidnapperList.getCompound(i);
|
||||||
|
if (uuidTag.contains("uuid")) {
|
||||||
|
data.linkedKidnapperUUIDs.add(uuidTag.getUUID("uuid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Load maid death time
|
||||||
|
if (tag.contains("maidDeathTime")) {
|
||||||
|
data.maidDeathTime = tag.getLong("maidDeathTime");
|
||||||
|
}
|
||||||
|
// Load loot chest positions
|
||||||
|
if (tag.contains("lootChestPositions")) {
|
||||||
|
ListTag lootList = tag.getList(
|
||||||
|
"lootChestPositions",
|
||||||
|
Tag.TAG_COMPOUND
|
||||||
|
);
|
||||||
|
for (int i = 0; i < lootList.size(); i++) {
|
||||||
|
CompoundTag posTag = lootList.getCompound(i);
|
||||||
|
data.lootChestPositions.add(
|
||||||
|
new BlockPos(
|
||||||
|
posTag.getInt("x"),
|
||||||
|
posTag.getInt("y"),
|
||||||
|
posTag.getInt("z")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STATIC ACCESS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CampOwnership registry for a server level.
|
||||||
|
*/
|
||||||
|
public static CampOwnership get(ServerLevel level) {
|
||||||
|
return level
|
||||||
|
.getDataStorage()
|
||||||
|
.computeIfAbsent(
|
||||||
|
CampOwnership::load,
|
||||||
|
CampOwnership::new,
|
||||||
|
DATA_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CampOwnership from a MinecraftServer.
|
||||||
|
*/
|
||||||
|
public static CampOwnership get(MinecraftServer server) {
|
||||||
|
ServerLevel overworld = server.overworld();
|
||||||
|
return get(overworld);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CAMP MANAGEMENT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new camp with its trader and maid.
|
||||||
|
*
|
||||||
|
* @param campId The camp/structure UUID
|
||||||
|
* @param traderUUID The SlaveTrader entity UUID
|
||||||
|
* @param maidUUID The Maid entity UUID (can be null)
|
||||||
|
* @param center The center position of the camp
|
||||||
|
*/
|
||||||
|
public void registerCamp(
|
||||||
|
UUID campId,
|
||||||
|
UUID traderUUID,
|
||||||
|
@Nullable UUID maidUUID,
|
||||||
|
BlockPos center
|
||||||
|
) {
|
||||||
|
CampData data = new CampData(campId, traderUUID, maidUUID, center);
|
||||||
|
camps.put(campId, data);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a camp is alive (has living trader).
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @return true if camp exists and is alive
|
||||||
|
*/
|
||||||
|
public boolean isCampAlive(UUID campId) {
|
||||||
|
CampData data = camps.get(campId);
|
||||||
|
return data != null && data.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camp data by camp UUID.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData getCamp(UUID campId) {
|
||||||
|
return camps.get(campId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camp data by trader UUID.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData getCampByTrader(UUID traderUUID) {
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (traderUUID.equals(camp.getTraderUUID())) {
|
||||||
|
return camp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get camp data by maid UUID.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData getCampByMaid(UUID maidUUID) {
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (maidUUID.equals(camp.getMaidUUID())) {
|
||||||
|
return camp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find camps near a position.
|
||||||
|
*
|
||||||
|
* @param center The center position
|
||||||
|
* @param radius The search radius
|
||||||
|
* @return List of camps within radius
|
||||||
|
*/
|
||||||
|
public List<CampData> findCampsNear(BlockPos center, double radius) {
|
||||||
|
List<CampData> nearby = new ArrayList<>();
|
||||||
|
double radiusSq = radius * radius;
|
||||||
|
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (
|
||||||
|
camp.getCenter() != null &&
|
||||||
|
camp.getCenter().distSqr(center) <= radiusSq
|
||||||
|
) {
|
||||||
|
nearby.add(camp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearby;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the nearest alive camp to a position.
|
||||||
|
*
|
||||||
|
* @param pos The position to search from
|
||||||
|
* @param radius Maximum search radius
|
||||||
|
* @return The nearest alive camp, or null
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData findNearestAliveCamp(BlockPos pos, double radius) {
|
||||||
|
CampData nearest = null;
|
||||||
|
double nearestDistSq = radius * radius;
|
||||||
|
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (!camp.isAlive() || camp.getCenter() == null) continue;
|
||||||
|
|
||||||
|
double distSq = camp.getCenter().distSqr(pos);
|
||||||
|
if (distSq < nearestDistSq) {
|
||||||
|
nearestDistSq = distSq;
|
||||||
|
nearest = camp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a camp from the registry.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData removeCamp(UUID campId) {
|
||||||
|
CampData removed = camps.remove(campId);
|
||||||
|
if (removed != null) {
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered camps.
|
||||||
|
*/
|
||||||
|
public Collection<CampData> getAllCamps() {
|
||||||
|
return Collections.unmodifiableCollection(camps.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all alive camps.
|
||||||
|
*/
|
||||||
|
public List<CampData> getAliveCamps() {
|
||||||
|
List<CampData> alive = new ArrayList<>();
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (camp.isAlive()) {
|
||||||
|
alive.add(camp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a kidnapper to a camp.
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param kidnapperUUID The kidnapper UUID
|
||||||
|
*/
|
||||||
|
public void linkKidnapperToCamp(UUID campId, UUID kidnapperUUID) {
|
||||||
|
CampData data = camps.get(campId);
|
||||||
|
if (data != null) {
|
||||||
|
data.addKidnapper(kidnapperUUID);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a kidnapper from a camp.
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param kidnapperUUID The kidnapper UUID
|
||||||
|
*/
|
||||||
|
public void unlinkKidnapperFromCamp(UUID campId, UUID kidnapperUUID) {
|
||||||
|
CampData data = camps.get(campId);
|
||||||
|
if (data != null) {
|
||||||
|
data.removeKidnapper(kidnapperUUID);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a kidnapper is linked to a camp.
|
||||||
|
*
|
||||||
|
* @param campId The camp UUID
|
||||||
|
* @param kidnapperUUID The kidnapper UUID
|
||||||
|
* @return true if the kidnapper is linked to this camp
|
||||||
|
*/
|
||||||
|
public boolean isKidnapperLinked(UUID campId, UUID kidnapperUUID) {
|
||||||
|
CampData data = camps.get(campId);
|
||||||
|
return data != null && data.hasKidnapper(kidnapperUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the camp a kidnapper is linked to.
|
||||||
|
*
|
||||||
|
* @param kidnapperUUID The kidnapper UUID
|
||||||
|
* @return The camp data, or null if not linked
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public CampData findCampByKidnapper(UUID kidnapperUUID) {
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
if (camp.hasKidnapper(kidnapperUUID)) {
|
||||||
|
return camp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a prisoner as processed (to avoid re-processing).
|
||||||
|
*
|
||||||
|
* @param prisonerId The prisoner's UUID
|
||||||
|
*/
|
||||||
|
public void markPrisonerProcessed(UUID prisonerId) {
|
||||||
|
processedPrisoners.add(prisonerId);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a prisoner has been processed.
|
||||||
|
*
|
||||||
|
* @param prisonerId The prisoner's UUID
|
||||||
|
* @return true if already processed
|
||||||
|
*/
|
||||||
|
public boolean isPrisonerProcessed(UUID prisonerId) {
|
||||||
|
return processedPrisoners.contains(prisonerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a prisoner from processed set.
|
||||||
|
*
|
||||||
|
* @param prisonerId The prisoner's UUID
|
||||||
|
*/
|
||||||
|
public void unmarkPrisonerProcessed(UUID prisonerId) {
|
||||||
|
if (processedPrisoners.remove(prisonerId)) {
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of processed prisoners for a camp (unmodifiable).
|
||||||
|
*
|
||||||
|
* @return Unmodifiable set of processed prisoner UUIDs
|
||||||
|
*/
|
||||||
|
public Set<UUID> getProcessedPrisoners() {
|
||||||
|
return Collections.unmodifiableSet(processedPrisoners);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERSISTENCE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
|
||||||
|
// Save camps
|
||||||
|
ListTag campList = new ListTag();
|
||||||
|
for (CampData camp : camps.values()) {
|
||||||
|
campList.add(camp.save());
|
||||||
|
}
|
||||||
|
tag.put("camps", campList);
|
||||||
|
|
||||||
|
// Save processed prisoners
|
||||||
|
ListTag processedList = new ListTag();
|
||||||
|
for (UUID uuid : processedPrisoners) {
|
||||||
|
CompoundTag uuidTag = new CompoundTag();
|
||||||
|
uuidTag.putUUID("uuid", uuid);
|
||||||
|
processedList.add(uuidTag);
|
||||||
|
}
|
||||||
|
tag.put("processedPrisoners", processedList);
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CampOwnership load(CompoundTag tag) {
|
||||||
|
CampOwnership registry = new CampOwnership();
|
||||||
|
|
||||||
|
// Load camps
|
||||||
|
if (tag.contains("camps")) {
|
||||||
|
ListTag campList = tag.getList("camps", Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < campList.size(); i++) {
|
||||||
|
CampData camp = CampData.load(campList.getCompound(i));
|
||||||
|
registry.camps.put(camp.getCampId(), camp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load processed prisoners
|
||||||
|
if (tag.contains("processedPrisoners")) {
|
||||||
|
ListTag processedList = tag.getList(
|
||||||
|
"processedPrisoners",
|
||||||
|
Tag.TAG_COMPOUND
|
||||||
|
);
|
||||||
|
for (int i = 0; i < processedList.size(); i++) {
|
||||||
|
CompoundTag uuidTag = processedList.getCompound(i);
|
||||||
|
if (uuidTag.contains("uuid")) {
|
||||||
|
registry.processedPrisoners.add(uuidTag.getUUID("uuid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
607
src/main/java/com/tiedup/remake/cells/CellDataV2.java
Normal file
607
src/main/java/com/tiedup/remake/cells/CellDataV2.java
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.NbtUtils;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell data for Cell System V2.
|
||||||
|
*
|
||||||
|
* Named CellDataV2 to coexist with v1 CellData during the migration period.
|
||||||
|
* Contains geometry (interior + walls from flood-fill), breach tracking,
|
||||||
|
* prisoner management, and auto-detected features.
|
||||||
|
*/
|
||||||
|
public class CellDataV2 {
|
||||||
|
|
||||||
|
private static final int MAX_PRISONERS = 4;
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
private final UUID id;
|
||||||
|
private CellState state;
|
||||||
|
private final BlockPos corePos;
|
||||||
|
|
||||||
|
// Cached from Core BE (for use when chunk is unloaded)
|
||||||
|
@Nullable
|
||||||
|
private BlockPos spawnPoint;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BlockPos deliveryPoint;
|
||||||
|
|
||||||
|
// Ownership
|
||||||
|
@Nullable
|
||||||
|
private UUID ownerId;
|
||||||
|
|
||||||
|
private CellOwnerType ownerType = CellOwnerType.PLAYER;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
// Geometry (from flood-fill)
|
||||||
|
private final Set<BlockPos> interiorBlocks;
|
||||||
|
private final Set<BlockPos> wallBlocks;
|
||||||
|
private final Set<BlockPos> breachedPositions;
|
||||||
|
private int totalWallCount;
|
||||||
|
|
||||||
|
// Interior face direction (which face of Core points inside)
|
||||||
|
@Nullable
|
||||||
|
private Direction interiorFace;
|
||||||
|
|
||||||
|
// Auto-detected features
|
||||||
|
private final List<BlockPos> beds;
|
||||||
|
private final List<BlockPos> petBeds;
|
||||||
|
private final List<BlockPos> anchors;
|
||||||
|
private final List<BlockPos> doors;
|
||||||
|
private final List<BlockPos> linkedRedstone;
|
||||||
|
|
||||||
|
// Prisoners
|
||||||
|
private final List<UUID> prisonerIds = new CopyOnWriteArrayList<>();
|
||||||
|
private final Map<UUID, Long> prisonerTimestamps =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Camp navigation
|
||||||
|
private final List<BlockPos> pathWaypoints = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
// ==================== CONSTRUCTORS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from a successful flood-fill result.
|
||||||
|
*/
|
||||||
|
public CellDataV2(BlockPos corePos, FloodFillResult result) {
|
||||||
|
this.id = UUID.randomUUID();
|
||||||
|
this.state = CellState.INTACT;
|
||||||
|
this.corePos = corePos.immutable();
|
||||||
|
this.interiorFace = result.getInteriorFace();
|
||||||
|
|
||||||
|
this.interiorBlocks = ConcurrentHashMap.newKeySet();
|
||||||
|
this.interiorBlocks.addAll(result.getInterior());
|
||||||
|
|
||||||
|
this.wallBlocks = ConcurrentHashMap.newKeySet();
|
||||||
|
this.wallBlocks.addAll(result.getWalls());
|
||||||
|
|
||||||
|
this.breachedPositions = ConcurrentHashMap.newKeySet();
|
||||||
|
this.totalWallCount = result.getWalls().size();
|
||||||
|
|
||||||
|
this.beds = new CopyOnWriteArrayList<>(result.getBeds());
|
||||||
|
this.petBeds = new CopyOnWriteArrayList<>(result.getPetBeds());
|
||||||
|
this.anchors = new CopyOnWriteArrayList<>(result.getAnchors());
|
||||||
|
this.doors = new CopyOnWriteArrayList<>(result.getDoors());
|
||||||
|
this.linkedRedstone = new CopyOnWriteArrayList<>(
|
||||||
|
result.getLinkedRedstone()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create for NBT loading (minimal constructor).
|
||||||
|
*/
|
||||||
|
public CellDataV2(UUID id, BlockPos corePos) {
|
||||||
|
this.id = id;
|
||||||
|
this.state = CellState.INTACT;
|
||||||
|
this.corePos = corePos.immutable();
|
||||||
|
|
||||||
|
this.interiorBlocks = ConcurrentHashMap.newKeySet();
|
||||||
|
this.wallBlocks = ConcurrentHashMap.newKeySet();
|
||||||
|
this.breachedPositions = ConcurrentHashMap.newKeySet();
|
||||||
|
this.totalWallCount = 0;
|
||||||
|
|
||||||
|
this.beds = new CopyOnWriteArrayList<>();
|
||||||
|
this.petBeds = new CopyOnWriteArrayList<>();
|
||||||
|
this.anchors = new CopyOnWriteArrayList<>();
|
||||||
|
this.doors = new CopyOnWriteArrayList<>();
|
||||||
|
this.linkedRedstone = new CopyOnWriteArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IDENTITY ====================
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CellState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(CellState state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BlockPos getCorePos() {
|
||||||
|
return corePos;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockPos getSpawnPoint() {
|
||||||
|
return spawnPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpawnPoint(@Nullable BlockPos spawnPoint) {
|
||||||
|
this.spawnPoint = spawnPoint != null ? spawnPoint.immutable() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public BlockPos getDeliveryPoint() {
|
||||||
|
return deliveryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) {
|
||||||
|
this.deliveryPoint =
|
||||||
|
deliveryPoint != null ? deliveryPoint.immutable() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Direction getInteriorFace() {
|
||||||
|
return interiorFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== OWNERSHIP ====================
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public UUID getOwnerId() {
|
||||||
|
return ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerId(@Nullable UUID ownerId) {
|
||||||
|
this.ownerId = ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CellOwnerType getOwnerType() {
|
||||||
|
return ownerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerType(CellOwnerType ownerType) {
|
||||||
|
this.ownerType = ownerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(@Nullable String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOwnedBy(UUID playerId) {
|
||||||
|
return playerId != null && playerId.equals(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a player can manage this cell (open menu, rename, modify settings).
|
||||||
|
* - OPs (level 2+) can always manage any cell (including camp-owned).
|
||||||
|
* - Player-owned cells: only the owning player.
|
||||||
|
* - Camp-owned cells: OPs only.
|
||||||
|
*
|
||||||
|
* @param playerId UUID of the player
|
||||||
|
* @param hasOpPerms true if the player has OP level 2+
|
||||||
|
* @return true if the player is allowed to manage this cell
|
||||||
|
*/
|
||||||
|
public boolean canPlayerManage(UUID playerId, boolean hasOpPerms) {
|
||||||
|
if (hasOpPerms) return true;
|
||||||
|
if (isCampOwned()) return false;
|
||||||
|
return isOwnedBy(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasOwner() {
|
||||||
|
return ownerId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCampOwned() {
|
||||||
|
return ownerType == CellOwnerType.CAMP;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPlayerOwned() {
|
||||||
|
return ownerType == CellOwnerType.PLAYER;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public UUID getCampId() {
|
||||||
|
return isCampOwned() ? ownerId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GEOMETRY ====================
|
||||||
|
|
||||||
|
public Set<BlockPos> getInteriorBlocks() {
|
||||||
|
return Collections.unmodifiableSet(interiorBlocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<BlockPos> getWallBlocks() {
|
||||||
|
return Collections.unmodifiableSet(wallBlocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<BlockPos> getBreachedPositions() {
|
||||||
|
return Collections.unmodifiableSet(breachedPositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalWallCount() {
|
||||||
|
return totalWallCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isContainedInCell(BlockPos pos) {
|
||||||
|
return interiorBlocks.contains(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isWallBlock(BlockPos pos) {
|
||||||
|
return wallBlocks.contains(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BREACH MANAGEMENT ====================
|
||||||
|
|
||||||
|
public void addBreach(BlockPos wallPos) {
|
||||||
|
if (wallBlocks.remove(wallPos)) {
|
||||||
|
breachedPositions.add(wallPos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void repairBreach(BlockPos wallPos) {
|
||||||
|
if (breachedPositions.remove(wallPos)) {
|
||||||
|
wallBlocks.add(wallPos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getBreachPercentage() {
|
||||||
|
if (totalWallCount == 0) return 0.0f;
|
||||||
|
return (float) breachedPositions.size() / totalWallCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FEATURES ====================
|
||||||
|
|
||||||
|
public List<BlockPos> getBeds() {
|
||||||
|
return Collections.unmodifiableList(beds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getPetBeds() {
|
||||||
|
return Collections.unmodifiableList(petBeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getAnchors() {
|
||||||
|
return Collections.unmodifiableList(anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getDoors() {
|
||||||
|
return Collections.unmodifiableList(doors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getLinkedRedstone() {
|
||||||
|
return Collections.unmodifiableList(linkedRedstone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PRISONER MANAGEMENT ====================
|
||||||
|
|
||||||
|
public List<UUID> getPrisonerIds() {
|
||||||
|
return Collections.unmodifiableList(prisonerIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasPrisoner(UUID prisonerId) {
|
||||||
|
return prisonerIds.contains(prisonerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFull() {
|
||||||
|
return prisonerIds.size() >= MAX_PRISONERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOccupied() {
|
||||||
|
return !prisonerIds.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPrisonerCount() {
|
||||||
|
return prisonerIds.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean addPrisoner(UUID prisonerId) {
|
||||||
|
if (isFull() || prisonerIds.contains(prisonerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
prisonerIds.add(prisonerId);
|
||||||
|
prisonerTimestamps.put(prisonerId, System.currentTimeMillis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean removePrisoner(UUID prisonerId) {
|
||||||
|
boolean removed = prisonerIds.remove(prisonerId);
|
||||||
|
if (removed) {
|
||||||
|
prisonerTimestamps.remove(prisonerId);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Long getPrisonerTimestamp(UUID prisonerId) {
|
||||||
|
return prisonerTimestamps.get(prisonerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CAMP NAVIGATION ====================
|
||||||
|
|
||||||
|
public List<BlockPos> getPathWaypoints() {
|
||||||
|
return Collections.unmodifiableList(pathWaypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPathWaypoints(List<BlockPos> waypoints) {
|
||||||
|
pathWaypoints.clear();
|
||||||
|
pathWaypoints.addAll(waypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WIRE RECONSTRUCTION (client-side) ====================
|
||||||
|
|
||||||
|
/** Add a wall block position (used for client-side packet reconstruction). */
|
||||||
|
public void addWallBlock(BlockPos pos) {
|
||||||
|
wallBlocks.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a bed position (used for client-side packet reconstruction). */
|
||||||
|
public void addBed(BlockPos pos) {
|
||||||
|
beds.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an anchor position (used for client-side packet reconstruction). */
|
||||||
|
public void addAnchor(BlockPos pos) {
|
||||||
|
anchors.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a door position (used for client-side packet reconstruction). */
|
||||||
|
public void addDoor(BlockPos pos) {
|
||||||
|
doors.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GEOMETRY UPDATE (rescan) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace geometry with a new flood-fill result (used during rescan).
|
||||||
|
*/
|
||||||
|
public void updateGeometry(FloodFillResult result) {
|
||||||
|
interiorBlocks.clear();
|
||||||
|
interiorBlocks.addAll(result.getInterior());
|
||||||
|
|
||||||
|
wallBlocks.clear();
|
||||||
|
wallBlocks.addAll(result.getWalls());
|
||||||
|
|
||||||
|
breachedPositions.clear();
|
||||||
|
totalWallCount = result.getWalls().size();
|
||||||
|
|
||||||
|
if (result.getInteriorFace() != null) {
|
||||||
|
this.interiorFace = result.getInteriorFace();
|
||||||
|
}
|
||||||
|
|
||||||
|
beds.clear();
|
||||||
|
beds.addAll(result.getBeds());
|
||||||
|
petBeds.clear();
|
||||||
|
petBeds.addAll(result.getPetBeds());
|
||||||
|
anchors.clear();
|
||||||
|
anchors.addAll(result.getAnchors());
|
||||||
|
doors.clear();
|
||||||
|
doors.addAll(result.getDoors());
|
||||||
|
linkedRedstone.clear();
|
||||||
|
linkedRedstone.addAll(result.getLinkedRedstone());
|
||||||
|
|
||||||
|
state = CellState.INTACT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NBT PERSISTENCE ====================
|
||||||
|
|
||||||
|
public CompoundTag save() {
|
||||||
|
CompoundTag tag = new CompoundTag();
|
||||||
|
|
||||||
|
tag.putUUID("id", id);
|
||||||
|
tag.putString("state", state.getSerializedName());
|
||||||
|
tag.put("corePos", NbtUtils.writeBlockPos(corePos));
|
||||||
|
|
||||||
|
if (spawnPoint != null) {
|
||||||
|
tag.put("spawnPoint", NbtUtils.writeBlockPos(spawnPoint));
|
||||||
|
}
|
||||||
|
if (deliveryPoint != null) {
|
||||||
|
tag.put("deliveryPoint", NbtUtils.writeBlockPos(deliveryPoint));
|
||||||
|
}
|
||||||
|
if (interiorFace != null) {
|
||||||
|
tag.putString("interiorFace", interiorFace.getSerializedName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership
|
||||||
|
if (ownerId != null) {
|
||||||
|
tag.putUUID("ownerId", ownerId);
|
||||||
|
}
|
||||||
|
tag.putString("ownerType", ownerType.getSerializedName());
|
||||||
|
if (name != null) {
|
||||||
|
tag.putString("name", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geometry
|
||||||
|
tag.put("interior", saveBlockPosSet(interiorBlocks));
|
||||||
|
tag.put("walls", saveBlockPosSet(wallBlocks));
|
||||||
|
tag.put("breached", saveBlockPosSet(breachedPositions));
|
||||||
|
tag.putInt("totalWallCount", totalWallCount);
|
||||||
|
|
||||||
|
// Features
|
||||||
|
tag.put("beds", saveBlockPosList(beds));
|
||||||
|
tag.put("petBeds", saveBlockPosList(petBeds));
|
||||||
|
tag.put("anchors", saveBlockPosList(anchors));
|
||||||
|
tag.put("doors", saveBlockPosList(doors));
|
||||||
|
tag.put("linkedRedstone", saveBlockPosList(linkedRedstone));
|
||||||
|
|
||||||
|
// Prisoners
|
||||||
|
if (!prisonerIds.isEmpty()) {
|
||||||
|
ListTag prisonerList = new ListTag();
|
||||||
|
for (UUID uuid : prisonerIds) {
|
||||||
|
CompoundTag prisonerTag = new CompoundTag();
|
||||||
|
prisonerTag.putUUID("id", uuid);
|
||||||
|
prisonerTag.putLong(
|
||||||
|
"timestamp",
|
||||||
|
prisonerTimestamps.getOrDefault(
|
||||||
|
uuid,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
prisonerList.add(prisonerTag);
|
||||||
|
}
|
||||||
|
tag.put("prisoners", prisonerList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path waypoints
|
||||||
|
if (!pathWaypoints.isEmpty()) {
|
||||||
|
tag.put("pathWaypoints", saveBlockPosList(pathWaypoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static CellDataV2 load(CompoundTag tag) {
|
||||||
|
if (!tag.contains("id") || !tag.contains("corePos")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID id = tag.getUUID("id");
|
||||||
|
BlockPos corePos = NbtUtils.readBlockPos(tag.getCompound("corePos"));
|
||||||
|
CellDataV2 cell = new CellDataV2(id, corePos);
|
||||||
|
|
||||||
|
cell.state = CellState.fromString(tag.getString("state"));
|
||||||
|
|
||||||
|
if (tag.contains("spawnPoint")) {
|
||||||
|
cell.spawnPoint = NbtUtils.readBlockPos(
|
||||||
|
tag.getCompound("spawnPoint")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tag.contains("deliveryPoint")) {
|
||||||
|
cell.deliveryPoint = NbtUtils.readBlockPos(
|
||||||
|
tag.getCompound("deliveryPoint")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tag.contains("interiorFace")) {
|
||||||
|
cell.interiorFace = Direction.byName(tag.getString("interiorFace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership
|
||||||
|
if (tag.contains("ownerId")) {
|
||||||
|
cell.ownerId = tag.getUUID("ownerId");
|
||||||
|
}
|
||||||
|
if (tag.contains("ownerType")) {
|
||||||
|
cell.ownerType = CellOwnerType.fromString(
|
||||||
|
tag.getString("ownerType")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tag.contains("name")) {
|
||||||
|
cell.name = tag.getString("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geometry
|
||||||
|
loadBlockPosSet(tag, "interior", cell.interiorBlocks);
|
||||||
|
loadBlockPosSet(tag, "walls", cell.wallBlocks);
|
||||||
|
loadBlockPosSet(tag, "breached", cell.breachedPositions);
|
||||||
|
cell.totalWallCount = tag.getInt("totalWallCount");
|
||||||
|
|
||||||
|
// Features
|
||||||
|
loadBlockPosList(tag, "beds", cell.beds);
|
||||||
|
loadBlockPosList(tag, "petBeds", cell.petBeds);
|
||||||
|
loadBlockPosList(tag, "anchors", cell.anchors);
|
||||||
|
loadBlockPosList(tag, "doors", cell.doors);
|
||||||
|
loadBlockPosList(tag, "linkedRedstone", cell.linkedRedstone);
|
||||||
|
|
||||||
|
// Prisoners
|
||||||
|
if (tag.contains("prisoners")) {
|
||||||
|
ListTag prisonerList = tag.getList("prisoners", Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < prisonerList.size(); i++) {
|
||||||
|
CompoundTag prisonerTag = prisonerList.getCompound(i);
|
||||||
|
UUID prisonerId = prisonerTag.getUUID("id");
|
||||||
|
cell.prisonerIds.add(prisonerId);
|
||||||
|
long timestamp = prisonerTag.contains("timestamp")
|
||||||
|
? prisonerTag.getLong("timestamp")
|
||||||
|
: System.currentTimeMillis();
|
||||||
|
cell.prisonerTimestamps.put(prisonerId, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path waypoints
|
||||||
|
loadBlockPosList(tag, "pathWaypoints", cell.pathWaypoints);
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NBT HELPERS ====================
|
||||||
|
|
||||||
|
private static ListTag saveBlockPosSet(Set<BlockPos> positions) {
|
||||||
|
ListTag list = new ListTag();
|
||||||
|
for (BlockPos pos : positions) {
|
||||||
|
list.add(NbtUtils.writeBlockPos(pos));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ListTag saveBlockPosList(List<BlockPos> positions) {
|
||||||
|
ListTag list = new ListTag();
|
||||||
|
for (BlockPos pos : positions) {
|
||||||
|
list.add(NbtUtils.writeBlockPos(pos));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void loadBlockPosSet(
|
||||||
|
CompoundTag parent,
|
||||||
|
String key,
|
||||||
|
Set<BlockPos> target
|
||||||
|
) {
|
||||||
|
if (parent.contains(key)) {
|
||||||
|
ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < list.size(); i++) {
|
||||||
|
target.add(NbtUtils.readBlockPos(list.getCompound(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void loadBlockPosList(
|
||||||
|
CompoundTag parent,
|
||||||
|
String key,
|
||||||
|
List<BlockPos> target
|
||||||
|
) {
|
||||||
|
if (parent.contains(key)) {
|
||||||
|
ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < list.size(); i++) {
|
||||||
|
target.add(NbtUtils.readBlockPos(list.getCompound(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEBUG ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return (
|
||||||
|
"CellDataV2{id=" +
|
||||||
|
id.toString().substring(0, 8) +
|
||||||
|
"..., state=" +
|
||||||
|
state +
|
||||||
|
", core=" +
|
||||||
|
corePos.toShortString() +
|
||||||
|
", interior=" +
|
||||||
|
interiorBlocks.size() +
|
||||||
|
", walls=" +
|
||||||
|
wallBlocks.size() +
|
||||||
|
", prisoners=" +
|
||||||
|
prisonerIds.size() +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/tiedup/remake/cells/CellOwnerType.java
Normal file
33
src/main/java/com/tiedup/remake/cells/CellOwnerType.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum indicating who owns a cell.
|
||||||
|
* Used to distinguish player-created cells from camp-generated cells.
|
||||||
|
*
|
||||||
|
* Extracted from CellData.OwnerType to decouple V2 code from V1 CellData.
|
||||||
|
*/
|
||||||
|
public enum CellOwnerType {
|
||||||
|
/** Cell created by a player using Cell Wand */
|
||||||
|
PLAYER("player"),
|
||||||
|
/** Cell generated with a kidnapper camp structure */
|
||||||
|
CAMP("camp");
|
||||||
|
|
||||||
|
private final String serializedName;
|
||||||
|
|
||||||
|
CellOwnerType(String serializedName) {
|
||||||
|
this.serializedName = serializedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSerializedName() {
|
||||||
|
return serializedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CellOwnerType fromString(String name) {
|
||||||
|
for (CellOwnerType type : values()) {
|
||||||
|
if (type.serializedName.equalsIgnoreCase(name)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PLAYER; // Default to PLAYER for backwards compatibility
|
||||||
|
}
|
||||||
|
}
|
||||||
903
src/main/java/com/tiedup/remake/cells/CellRegistryV2.java
Normal file
903
src/main/java/com/tiedup/remake/cells/CellRegistryV2.java
Normal file
@@ -0,0 +1,903 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.SystemMessageManager;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.level.ChunkPos;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global registry for Cell System V2 data.
|
||||||
|
*
|
||||||
|
* Named CellRegistryV2 to coexist with v1 CellRegistry during migration.
|
||||||
|
* Uses "tiedup_cell_registry_v2" as the SavedData name.
|
||||||
|
*
|
||||||
|
* Provides spatial indices for fast lookups by wall position, interior position,
|
||||||
|
* core position, chunk, and camp.
|
||||||
|
*/
|
||||||
|
public class CellRegistryV2 extends SavedData {
|
||||||
|
|
||||||
|
private static final String DATA_NAME = "tiedup_cell_registry_v2";
|
||||||
|
|
||||||
|
/** Reservation timeout in ticks (30 seconds = 600 ticks) */
|
||||||
|
private static final long RESERVATION_TIMEOUT_TICKS = 600L;
|
||||||
|
|
||||||
|
// ==================== RESERVATION ====================
|
||||||
|
|
||||||
|
private static class CellReservation {
|
||||||
|
|
||||||
|
private final UUID kidnapperUUID;
|
||||||
|
private final long expiryTime;
|
||||||
|
|
||||||
|
public CellReservation(UUID kidnapperUUID, long expiryTime) {
|
||||||
|
this.kidnapperUUID = kidnapperUUID;
|
||||||
|
this.expiryTime = expiryTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getKidnapperUUID() {
|
||||||
|
return kidnapperUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired(long currentTime) {
|
||||||
|
return currentTime >= expiryTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STORAGE ====================
|
||||||
|
|
||||||
|
// Primary storage
|
||||||
|
private final Map<UUID, CellDataV2> cells = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Indices (rebuilt on load)
|
||||||
|
private final Map<BlockPos, UUID> wallToCell = new ConcurrentHashMap<>();
|
||||||
|
private final Map<BlockPos, UUID> interiorToCell =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
private final Map<BlockPos, UUID> coreToCell = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Spatial + camp indices
|
||||||
|
private final Map<ChunkPos, Set<UUID>> cellsByChunk =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
private final Map<UUID, Set<UUID>> cellsByCamp = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Breach tracking index (breached wall position → cell ID)
|
||||||
|
private final Map<BlockPos, UUID> breachedToCell =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Reservations (not persisted)
|
||||||
|
private final Map<UUID, CellReservation> reservations =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// ==================== STATIC ACCESS ====================
|
||||||
|
|
||||||
|
public static CellRegistryV2 get(ServerLevel level) {
|
||||||
|
return level
|
||||||
|
.getDataStorage()
|
||||||
|
.computeIfAbsent(
|
||||||
|
CellRegistryV2::load,
|
||||||
|
CellRegistryV2::new,
|
||||||
|
DATA_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CellRegistryV2 get(MinecraftServer server) {
|
||||||
|
return get(server.overworld());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CELL LIFECYCLE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new cell from a flood-fill result.
|
||||||
|
*/
|
||||||
|
public CellDataV2 createCell(
|
||||||
|
BlockPos corePos,
|
||||||
|
FloodFillResult result,
|
||||||
|
@Nullable UUID ownerId
|
||||||
|
) {
|
||||||
|
CellDataV2 cell = new CellDataV2(corePos, result);
|
||||||
|
if (ownerId != null) {
|
||||||
|
cell.setOwnerId(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.put(cell.getId(), cell);
|
||||||
|
|
||||||
|
// Register in indices
|
||||||
|
coreToCell.put(corePos.immutable(), cell.getId());
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.put(pos.immutable(), cell.getId());
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.put(pos.immutable(), cell.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
addToSpatialIndex(cell);
|
||||||
|
setDirty();
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a pre-constructed CellDataV2 (used by migration and structure loading).
|
||||||
|
* The cell must already have its ID and corePos set.
|
||||||
|
*/
|
||||||
|
public void registerExistingCell(CellDataV2 cell) {
|
||||||
|
cells.put(cell.getId(), cell);
|
||||||
|
coreToCell.put(cell.getCorePos().immutable(), cell.getId());
|
||||||
|
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.put(pos.immutable(), cell.getId());
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.put(pos.immutable(), cell.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
addToSpatialIndex(cell);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a cell from the registry and all indices.
|
||||||
|
*/
|
||||||
|
public void removeCell(UUID cellId) {
|
||||||
|
CellDataV2 cell = cells.remove(cellId);
|
||||||
|
if (cell == null) return;
|
||||||
|
|
||||||
|
coreToCell.remove(cell.getCorePos());
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.remove(pos);
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getBreachedPositions()) {
|
||||||
|
breachedToCell.remove(pos);
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromSpatialIndex(cell);
|
||||||
|
reservations.remove(cellId);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescan a cell with a new flood-fill result.
|
||||||
|
* Clears old indices and repopulates with new geometry.
|
||||||
|
*/
|
||||||
|
public void rescanCell(UUID cellId, FloodFillResult newResult) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null) return;
|
||||||
|
|
||||||
|
// Clear old indices for this cell
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.remove(pos);
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getBreachedPositions()) {
|
||||||
|
wallToCell.remove(pos);
|
||||||
|
breachedToCell.remove(pos);
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.remove(pos);
|
||||||
|
}
|
||||||
|
removeFromSpatialIndex(cell);
|
||||||
|
|
||||||
|
// Update geometry
|
||||||
|
cell.updateGeometry(newResult);
|
||||||
|
|
||||||
|
// Rebuild indices
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.put(pos.immutable(), cellId);
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.put(pos.immutable(), cellId);
|
||||||
|
}
|
||||||
|
addToSpatialIndex(cell);
|
||||||
|
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERIES ====================
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 getCell(UUID cellId) {
|
||||||
|
return cells.get(cellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 getCellAtCore(BlockPos corePos) {
|
||||||
|
UUID cellId = coreToCell.get(corePos);
|
||||||
|
return cellId != null ? cells.get(cellId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 getCellContaining(BlockPos pos) {
|
||||||
|
UUID cellId = interiorToCell.get(pos);
|
||||||
|
return cellId != null ? cells.get(cellId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 getCellByWall(BlockPos pos) {
|
||||||
|
UUID cellId = wallToCell.get(pos);
|
||||||
|
return cellId != null ? cells.get(cellId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public UUID getCellIdAtWall(BlockPos pos) {
|
||||||
|
return wallToCell.get(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInsideAnyCell(BlockPos pos) {
|
||||||
|
return interiorToCell.containsKey(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<CellDataV2> getAllCells() {
|
||||||
|
return Collections.unmodifiableCollection(cells.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCellCount() {
|
||||||
|
return cells.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CellDataV2> getCellsByCamp(UUID campId) {
|
||||||
|
Set<UUID> cellIds = cellsByCamp.get(campId);
|
||||||
|
if (cellIds == null) return Collections.emptyList();
|
||||||
|
|
||||||
|
List<CellDataV2> result = new ArrayList<>();
|
||||||
|
for (UUID cellId : cellIds) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell != null) {
|
||||||
|
result.add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CellDataV2> findCellsNear(BlockPos center, double radius) {
|
||||||
|
List<CellDataV2> nearby = new ArrayList<>();
|
||||||
|
double radiusSq = radius * radius;
|
||||||
|
|
||||||
|
int chunkRadius = (int) Math.ceil(radius / 16.0) + 1;
|
||||||
|
ChunkPos centerChunk = new ChunkPos(center);
|
||||||
|
|
||||||
|
for (int dx = -chunkRadius; dx <= chunkRadius; dx++) {
|
||||||
|
for (int dz = -chunkRadius; dz <= chunkRadius; dz++) {
|
||||||
|
ChunkPos checkChunk = new ChunkPos(
|
||||||
|
centerChunk.x + dx,
|
||||||
|
centerChunk.z + dz
|
||||||
|
);
|
||||||
|
Set<UUID> cellsInChunk = cellsByChunk.get(checkChunk);
|
||||||
|
|
||||||
|
if (cellsInChunk != null) {
|
||||||
|
for (UUID cellId : cellsInChunk) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (
|
||||||
|
cell != null &&
|
||||||
|
cell.getCorePos().distSqr(center) <= radiusSq
|
||||||
|
) {
|
||||||
|
nearby.add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearby;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 findCellByPrisoner(UUID prisonerId) {
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
if (cell.hasPrisoner(prisonerId)) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 getCellByName(String name) {
|
||||||
|
if (name == null || name.isEmpty()) return null;
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
if (name.equals(cell.getName())) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CellDataV2> getCellsByOwner(UUID ownerId) {
|
||||||
|
if (ownerId == null) return Collections.emptyList();
|
||||||
|
return cells
|
||||||
|
.values()
|
||||||
|
.stream()
|
||||||
|
.filter(c -> ownerId.equals(c.getOwnerId()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCellCountOwnedBy(UUID ownerId) {
|
||||||
|
if (ownerId == null) return 0;
|
||||||
|
return (int) cells
|
||||||
|
.values()
|
||||||
|
.stream()
|
||||||
|
.filter(c -> ownerId.equals(c.getOwnerId()))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public UUID getNextCellId(@Nullable UUID currentId) {
|
||||||
|
if (cells.isEmpty()) return null;
|
||||||
|
List<UUID> ids = new ArrayList<>(cells.keySet());
|
||||||
|
if (currentId == null) return ids.get(0);
|
||||||
|
int index = ids.indexOf(currentId);
|
||||||
|
if (index < 0 || index >= ids.size() - 1) return ids.get(0);
|
||||||
|
return ids.get(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CAMP QUERIES ====================
|
||||||
|
|
||||||
|
public List<UUID> getPrisonersInCamp(UUID campId) {
|
||||||
|
if (campId == null) return Collections.emptyList();
|
||||||
|
return getCellsByCamp(campId)
|
||||||
|
.stream()
|
||||||
|
.flatMap(cell -> cell.getPrisonerIds().stream())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPrisonerCountInCamp(UUID campId) {
|
||||||
|
if (campId == null) return 0;
|
||||||
|
return getCellsByCamp(campId)
|
||||||
|
.stream()
|
||||||
|
.mapToInt(CellDataV2::getPrisonerCount)
|
||||||
|
.sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public CellDataV2 findAvailableCellInCamp(UUID campId) {
|
||||||
|
if (campId == null) return null;
|
||||||
|
for (CellDataV2 cell : getCellsByCamp(campId)) {
|
||||||
|
if (!cell.isFull()) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasCampCells(UUID campId) {
|
||||||
|
if (campId == null) return false;
|
||||||
|
Set<UUID> cellIds = cellsByCamp.get(campId);
|
||||||
|
return cellIds != null && !cellIds.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the camp index for a cell after ownership change.
|
||||||
|
* Removes from old camp index, adds to new if camp-owned.
|
||||||
|
*/
|
||||||
|
public void updateCampIndex(CellDataV2 cell, @Nullable UUID oldOwnerId) {
|
||||||
|
if (oldOwnerId != null) {
|
||||||
|
Set<UUID> oldCampCells = cellsByCamp.get(oldOwnerId);
|
||||||
|
if (oldCampCells != null) {
|
||||||
|
oldCampCells.remove(cell.getId());
|
||||||
|
if (oldCampCells.isEmpty()) {
|
||||||
|
cellsByCamp.remove(oldOwnerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.isCampOwned() && cell.getOwnerId() != null) {
|
||||||
|
cellsByCamp
|
||||||
|
.computeIfAbsent(cell.getOwnerId(), k ->
|
||||||
|
ConcurrentHashMap.newKeySet()
|
||||||
|
)
|
||||||
|
.add(cell.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PRISONER MANAGEMENT ====================
|
||||||
|
|
||||||
|
public synchronized boolean assignPrisoner(UUID cellId, UUID prisonerId) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null || cell.isFull()) return false;
|
||||||
|
|
||||||
|
// Ensure prisoner uniqueness across cells
|
||||||
|
CellDataV2 existingCell = findCellByPrisoner(prisonerId);
|
||||||
|
if (existingCell != null) {
|
||||||
|
if (existingCell.getId().equals(cellId)) {
|
||||||
|
return true; // Already in this cell
|
||||||
|
}
|
||||||
|
return false; // Already in another cell
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.addPrisoner(prisonerId)) {
|
||||||
|
setDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean releasePrisoner(
|
||||||
|
UUID cellId,
|
||||||
|
UUID prisonerId,
|
||||||
|
MinecraftServer server
|
||||||
|
) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null) return false;
|
||||||
|
|
||||||
|
if (cell.removePrisoner(prisonerId)) {
|
||||||
|
// Synchronize with PrisonerManager
|
||||||
|
if (cell.isCampOwned() && cell.getCampId() != null) {
|
||||||
|
com.tiedup.remake.prison.PrisonerManager manager =
|
||||||
|
com.tiedup.remake.prison.PrisonerManager.get(
|
||||||
|
server.overworld()
|
||||||
|
);
|
||||||
|
com.tiedup.remake.prison.PrisonerState currentState =
|
||||||
|
manager.getState(prisonerId);
|
||||||
|
|
||||||
|
boolean isBeingExtracted =
|
||||||
|
currentState ==
|
||||||
|
com.tiedup.remake.prison.PrisonerState.WORKING;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isBeingExtracted &&
|
||||||
|
currentState ==
|
||||||
|
com.tiedup.remake.prison.PrisonerState.IMPRISONED
|
||||||
|
) {
|
||||||
|
manager.release(
|
||||||
|
prisonerId,
|
||||||
|
server.overworld().getGameTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean assignPrisonerWithNotification(
|
||||||
|
UUID cellId,
|
||||||
|
UUID prisonerId,
|
||||||
|
MinecraftServer server,
|
||||||
|
String prisonerName
|
||||||
|
) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null || cell.isFull()) return false;
|
||||||
|
|
||||||
|
if (cell.addPrisoner(prisonerId)) {
|
||||||
|
setDirty();
|
||||||
|
if (cell.hasOwner()) {
|
||||||
|
notifyOwner(
|
||||||
|
server,
|
||||||
|
cell.getOwnerId(),
|
||||||
|
SystemMessageManager.MessageCategory.PRISONER_ARRIVED,
|
||||||
|
prisonerName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean releasePrisonerWithNotification(
|
||||||
|
UUID cellId,
|
||||||
|
UUID prisonerId,
|
||||||
|
MinecraftServer server,
|
||||||
|
String prisonerName,
|
||||||
|
boolean escaped
|
||||||
|
) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null) return false;
|
||||||
|
|
||||||
|
if (cell.removePrisoner(prisonerId)) {
|
||||||
|
// Synchronize with PrisonerManager
|
||||||
|
if (cell.isCampOwned() && cell.getCampId() != null) {
|
||||||
|
com.tiedup.remake.prison.PrisonerManager manager =
|
||||||
|
com.tiedup.remake.prison.PrisonerManager.get(
|
||||||
|
server.overworld()
|
||||||
|
);
|
||||||
|
com.tiedup.remake.prison.PrisonerState currentState =
|
||||||
|
manager.getState(prisonerId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentState ==
|
||||||
|
com.tiedup.remake.prison.PrisonerState.IMPRISONED
|
||||||
|
) {
|
||||||
|
manager.release(
|
||||||
|
prisonerId,
|
||||||
|
server.overworld().getGameTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirty();
|
||||||
|
|
||||||
|
if (cell.hasOwner()) {
|
||||||
|
SystemMessageManager.MessageCategory category = escaped
|
||||||
|
? SystemMessageManager.MessageCategory.PRISONER_ESCAPED
|
||||||
|
: SystemMessageManager.MessageCategory.PRISONER_RELEASED;
|
||||||
|
notifyOwner(server, cell.getOwnerId(), category, prisonerName);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int releasePrisonerFromAllCells(UUID prisonerId) {
|
||||||
|
int count = 0;
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
if (cell.removePrisoner(prisonerId)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline timeout for cleanup: 30 minutes */
|
||||||
|
private static final long OFFLINE_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||||
|
|
||||||
|
public int cleanupEscapedPrisoners(
|
||||||
|
ServerLevel level,
|
||||||
|
com.tiedup.remake.state.CollarRegistry collarRegistry,
|
||||||
|
double maxDistance
|
||||||
|
) {
|
||||||
|
int removed = 0;
|
||||||
|
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
List<UUID> toRemove = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UUID prisonerId : cell.getPrisonerIds()) {
|
||||||
|
boolean shouldRemove = false;
|
||||||
|
String reason = null;
|
||||||
|
|
||||||
|
ServerPlayer prisoner = level
|
||||||
|
.getServer()
|
||||||
|
.getPlayerList()
|
||||||
|
.getPlayer(prisonerId);
|
||||||
|
|
||||||
|
if (prisoner == null) {
|
||||||
|
Long timestamp = cell.getPrisonerTimestamp(prisonerId);
|
||||||
|
long ts =
|
||||||
|
timestamp != null
|
||||||
|
? timestamp
|
||||||
|
: System.currentTimeMillis();
|
||||||
|
long offlineDuration = System.currentTimeMillis() - ts;
|
||||||
|
|
||||||
|
if (offlineDuration > OFFLINE_TIMEOUT_MS) {
|
||||||
|
shouldRemove = true;
|
||||||
|
reason =
|
||||||
|
"offline for too long (" +
|
||||||
|
(offlineDuration / 60000) +
|
||||||
|
" minutes)";
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use corePos for distance check (V2 uses core position, not spawnPoint)
|
||||||
|
double distSq = prisoner
|
||||||
|
.blockPosition()
|
||||||
|
.distSqr(cell.getCorePos());
|
||||||
|
if (distSq > maxDistance * maxDistance) {
|
||||||
|
shouldRemove = true;
|
||||||
|
reason =
|
||||||
|
"too far from cell (" +
|
||||||
|
(int) Math.sqrt(distSq) +
|
||||||
|
" blocks)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!shouldRemove && !collarRegistry.hasOwners(prisonerId)
|
||||||
|
) {
|
||||||
|
shouldRemove = true;
|
||||||
|
reason = "no collar registered";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRemove) {
|
||||||
|
com.tiedup.remake.state.IBondageState state =
|
||||||
|
com.tiedup.remake.util.KidnappedHelper.getKidnappedState(
|
||||||
|
prisoner
|
||||||
|
);
|
||||||
|
if (state == null || !state.isCaptive()) {
|
||||||
|
shouldRemove = true;
|
||||||
|
reason = "no longer captive";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRemove) {
|
||||||
|
toRemove.add(prisonerId);
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[CellRegistryV2] Removing escaped prisoner {} from cell {} - reason: {}",
|
||||||
|
prisonerId.toString().substring(0, 8),
|
||||||
|
cell.getId().toString().substring(0, 8),
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (UUID id : toRemove) {
|
||||||
|
cell.removePrisoner(id);
|
||||||
|
|
||||||
|
if (cell.isCampOwned() && cell.getCampId() != null) {
|
||||||
|
com.tiedup.remake.prison.PrisonerManager manager =
|
||||||
|
com.tiedup.remake.prison.PrisonerManager.get(
|
||||||
|
level.getServer().overworld()
|
||||||
|
);
|
||||||
|
com.tiedup.remake.prison.PrisonerState currentState =
|
||||||
|
manager.getState(id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentState ==
|
||||||
|
com.tiedup.remake.prison.PrisonerState.IMPRISONED
|
||||||
|
) {
|
||||||
|
com.tiedup.remake.prison.service.PrisonerService.get().escape(
|
||||||
|
level,
|
||||||
|
id,
|
||||||
|
"offline_cleanup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NOTIFICATIONS ====================
|
||||||
|
|
||||||
|
private void notifyOwner(
|
||||||
|
MinecraftServer server,
|
||||||
|
UUID ownerId,
|
||||||
|
SystemMessageManager.MessageCategory category,
|
||||||
|
String prisonerName
|
||||||
|
) {
|
||||||
|
if (server == null || ownerId == null) return;
|
||||||
|
ServerPlayer owner = server.getPlayerList().getPlayer(ownerId);
|
||||||
|
if (owner != null) {
|
||||||
|
String template = SystemMessageManager.getTemplate(category);
|
||||||
|
String formattedMessage = String.format(template, prisonerName);
|
||||||
|
SystemMessageManager.sendToPlayer(
|
||||||
|
owner,
|
||||||
|
category,
|
||||||
|
formattedMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BREACH MANAGEMENT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a wall breach: updates CellDataV2 and indices atomically.
|
||||||
|
*/
|
||||||
|
public void addBreach(UUID cellId, BlockPos pos) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null) return;
|
||||||
|
|
||||||
|
cell.addBreach(pos);
|
||||||
|
wallToCell.remove(pos);
|
||||||
|
breachedToCell.put(pos.immutable(), cellId);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair a wall breach: updates CellDataV2 and indices atomically.
|
||||||
|
*/
|
||||||
|
public void repairBreach(UUID cellId, BlockPos pos) {
|
||||||
|
CellDataV2 cell = cells.get(cellId);
|
||||||
|
if (cell == null) return;
|
||||||
|
|
||||||
|
cell.repairBreach(pos);
|
||||||
|
breachedToCell.remove(pos);
|
||||||
|
wallToCell.put(pos.immutable(), cellId);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cell ID for a breached wall position.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public UUID getCellIdAtBreach(BlockPos pos) {
|
||||||
|
return breachedToCell.get(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RESERVATIONS ====================
|
||||||
|
|
||||||
|
public boolean reserveCell(UUID cellId, UUID kidnapperUUID, long gameTime) {
|
||||||
|
cleanupExpiredReservations(gameTime);
|
||||||
|
|
||||||
|
long expiryTime = gameTime + RESERVATION_TIMEOUT_TICKS;
|
||||||
|
CellReservation existing = reservations.get(cellId);
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.getKidnapperUUID().equals(kidnapperUUID)) {
|
||||||
|
reservations.put(
|
||||||
|
cellId,
|
||||||
|
new CellReservation(kidnapperUUID, expiryTime)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!existing.isExpired(gameTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reservations.put(
|
||||||
|
cellId,
|
||||||
|
new CellReservation(kidnapperUUID, expiryTime)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean consumeReservation(UUID cellId, UUID kidnapperUUID) {
|
||||||
|
CellReservation reservation = reservations.remove(cellId);
|
||||||
|
if (reservation == null) return false;
|
||||||
|
return reservation.getKidnapperUUID().equals(kidnapperUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReservedByOther(
|
||||||
|
UUID cellId,
|
||||||
|
@Nullable UUID kidnapperUUID,
|
||||||
|
long gameTime
|
||||||
|
) {
|
||||||
|
CellReservation reservation = reservations.get(cellId);
|
||||||
|
if (reservation == null) return false;
|
||||||
|
if (reservation.isExpired(gameTime)) {
|
||||||
|
reservations.remove(cellId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
kidnapperUUID == null ||
|
||||||
|
!reservation.getKidnapperUUID().equals(kidnapperUUID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelReservation(UUID cellId, UUID kidnapperUUID) {
|
||||||
|
CellReservation reservation = reservations.get(cellId);
|
||||||
|
if (
|
||||||
|
reservation != null &&
|
||||||
|
reservation.getKidnapperUUID().equals(kidnapperUUID)
|
||||||
|
) {
|
||||||
|
reservations.remove(cellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupExpiredReservations(long gameTime) {
|
||||||
|
reservations
|
||||||
|
.entrySet()
|
||||||
|
.removeIf(entry -> entry.getValue().isExpired(gameTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SPATIAL INDEX ====================
|
||||||
|
|
||||||
|
private void addToSpatialIndex(CellDataV2 cell) {
|
||||||
|
ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
|
||||||
|
cellsByChunk
|
||||||
|
.computeIfAbsent(chunkPos, k -> ConcurrentHashMap.newKeySet())
|
||||||
|
.add(cell.getId());
|
||||||
|
|
||||||
|
// Add to camp index if camp-owned
|
||||||
|
if (
|
||||||
|
cell.getOwnerType() == CellOwnerType.CAMP &&
|
||||||
|
cell.getOwnerId() != null
|
||||||
|
) {
|
||||||
|
cellsByCamp
|
||||||
|
.computeIfAbsent(cell.getOwnerId(), k ->
|
||||||
|
ConcurrentHashMap.newKeySet()
|
||||||
|
)
|
||||||
|
.add(cell.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeFromSpatialIndex(CellDataV2 cell) {
|
||||||
|
ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
|
||||||
|
Set<UUID> cellsInChunk = cellsByChunk.get(chunkPos);
|
||||||
|
if (cellsInChunk != null) {
|
||||||
|
cellsInChunk.remove(cell.getId());
|
||||||
|
if (cellsInChunk.isEmpty()) {
|
||||||
|
cellsByChunk.remove(chunkPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cell.getOwnerType() == CellOwnerType.CAMP &&
|
||||||
|
cell.getOwnerId() != null
|
||||||
|
) {
|
||||||
|
Set<UUID> cellsInCamp = cellsByCamp.get(cell.getOwnerId());
|
||||||
|
if (cellsInCamp != null) {
|
||||||
|
cellsInCamp.remove(cell.getId());
|
||||||
|
if (cellsInCamp.isEmpty()) {
|
||||||
|
cellsByCamp.remove(cell.getOwnerId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INDEX REBUILD ====================
|
||||||
|
|
||||||
|
private void rebuildIndices() {
|
||||||
|
wallToCell.clear();
|
||||||
|
interiorToCell.clear();
|
||||||
|
coreToCell.clear();
|
||||||
|
breachedToCell.clear();
|
||||||
|
cellsByChunk.clear();
|
||||||
|
cellsByCamp.clear();
|
||||||
|
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
coreToCell.put(cell.getCorePos(), cell.getId());
|
||||||
|
for (BlockPos pos : cell.getWallBlocks()) {
|
||||||
|
wallToCell.put(pos, cell.getId());
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getBreachedPositions()) {
|
||||||
|
breachedToCell.put(pos, cell.getId());
|
||||||
|
}
|
||||||
|
for (BlockPos pos : cell.getInteriorBlocks()) {
|
||||||
|
interiorToCell.put(pos, cell.getId());
|
||||||
|
}
|
||||||
|
addToSpatialIndex(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERSISTENCE ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
|
||||||
|
ListTag cellList = new ListTag();
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
cellList.add(cell.save());
|
||||||
|
}
|
||||||
|
tag.put("cells", cellList);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CellRegistryV2 load(CompoundTag tag) {
|
||||||
|
CellRegistryV2 registry = new CellRegistryV2();
|
||||||
|
|
||||||
|
if (tag.contains("cells")) {
|
||||||
|
ListTag cellList = tag.getList("cells", Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < cellList.size(); i++) {
|
||||||
|
CellDataV2 cell = CellDataV2.load(cellList.getCompound(i));
|
||||||
|
if (cell != null) {
|
||||||
|
registry.cells.put(cell.getId(), cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.rebuildIndices();
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEBUG ====================
|
||||||
|
|
||||||
|
public String toDebugString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("CellRegistryV2:\n");
|
||||||
|
sb.append(" Total cells: ").append(cells.size()).append("\n");
|
||||||
|
sb.append(" Wall index: ").append(wallToCell.size()).append("\n");
|
||||||
|
sb
|
||||||
|
.append(" Interior index: ")
|
||||||
|
.append(interiorToCell.size())
|
||||||
|
.append("\n");
|
||||||
|
sb.append(" Core index: ").append(coreToCell.size()).append("\n");
|
||||||
|
|
||||||
|
for (CellDataV2 cell : cells.values()) {
|
||||||
|
sb.append(" ").append(cell.toString()).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side manager tracking which players are in selection mode
|
||||||
|
* (Set Spawn, Set Delivery, Set Disguise) after clicking a Cell Core menu button.
|
||||||
|
*
|
||||||
|
* Static map pattern matching ForcedSeatingHandler.
|
||||||
|
*/
|
||||||
|
public class CellSelectionManager {
|
||||||
|
|
||||||
|
private static final long TIMEOUT_MS = 30 * 1000L;
|
||||||
|
private static final double MAX_DISTANCE_SQ = 10.0 * 10.0;
|
||||||
|
|
||||||
|
private static final ConcurrentHashMap<UUID, SelectionContext> selections =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public static class SelectionContext {
|
||||||
|
|
||||||
|
public final SelectionMode mode;
|
||||||
|
public final BlockPos corePos;
|
||||||
|
public final UUID cellId;
|
||||||
|
public final long startTimeMs;
|
||||||
|
public final BlockPos playerStartPos;
|
||||||
|
|
||||||
|
public SelectionContext(
|
||||||
|
SelectionMode mode,
|
||||||
|
BlockPos corePos,
|
||||||
|
UUID cellId,
|
||||||
|
BlockPos playerStartPos
|
||||||
|
) {
|
||||||
|
this.mode = mode;
|
||||||
|
this.corePos = corePos;
|
||||||
|
this.cellId = cellId;
|
||||||
|
this.startTimeMs = System.currentTimeMillis();
|
||||||
|
this.playerStartPos = playerStartPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void startSelection(
|
||||||
|
UUID playerId,
|
||||||
|
SelectionMode mode,
|
||||||
|
BlockPos corePos,
|
||||||
|
UUID cellId,
|
||||||
|
BlockPos playerPos
|
||||||
|
) {
|
||||||
|
selections.put(
|
||||||
|
playerId,
|
||||||
|
new SelectionContext(mode, corePos, cellId, playerPos)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static SelectionContext getSelection(UUID playerId) {
|
||||||
|
return selections.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clearSelection(UUID playerId) {
|
||||||
|
selections.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isInSelectionMode(UUID playerId) {
|
||||||
|
return selections.containsKey(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if selection should be cancelled due to timeout or distance.
|
||||||
|
*/
|
||||||
|
public static boolean shouldCancel(UUID playerId, BlockPos currentPos) {
|
||||||
|
SelectionContext ctx = selections.get(playerId);
|
||||||
|
if (ctx == null) return false;
|
||||||
|
|
||||||
|
// Timeout check
|
||||||
|
if (System.currentTimeMillis() - ctx.startTimeMs > TIMEOUT_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance check (from core, not player start)
|
||||||
|
if (ctx.corePos.distSqr(currentPos) > MAX_DISTANCE_SQ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on player disconnect to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
public static void cleanup(UUID playerId) {
|
||||||
|
selections.remove(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/tiedup/remake/cells/CellState.java
Normal file
33
src/main/java/com/tiedup/remake/cells/CellState.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of a Cell System V2 cell.
|
||||||
|
*
|
||||||
|
* INTACT — all walls present, fully operational.
|
||||||
|
* BREACHED — some walls broken, prisoners may escape.
|
||||||
|
* COMPROMISED — Core destroyed or too many walls broken; cell is non-functional.
|
||||||
|
*/
|
||||||
|
public enum CellState {
|
||||||
|
INTACT("intact"),
|
||||||
|
BREACHED("breached"),
|
||||||
|
COMPROMISED("compromised");
|
||||||
|
|
||||||
|
private final String serializedName;
|
||||||
|
|
||||||
|
CellState(String serializedName) {
|
||||||
|
this.serializedName = serializedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSerializedName() {
|
||||||
|
return serializedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CellState fromString(String name) {
|
||||||
|
for (CellState state : values()) {
|
||||||
|
if (state.serializedName.equalsIgnoreCase(name)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return INTACT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.ListTag;
|
||||||
|
import net.minecraft.nbt.NbtUtils;
|
||||||
|
import net.minecraft.nbt.Tag;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* When a player is imprisoned:
|
||||||
|
* 1. Their inventory is saved to NBT
|
||||||
|
* 2. Items are transferred to a LOOT chest in the cell
|
||||||
|
* 3. Player's inventory is cleared
|
||||||
|
* 4. Data is persisted for recovery after server restart
|
||||||
|
*
|
||||||
|
* Recovery options:
|
||||||
|
* - Player finds and opens the chest manually
|
||||||
|
* - Player escapes and returns to chest
|
||||||
|
* - Admin command /tiedup returnstuff <player>
|
||||||
|
*/
|
||||||
|
public class ConfiscatedInventoryRegistry extends SavedData {
|
||||||
|
|
||||||
|
private static final String DATA_NAME =
|
||||||
|
TiedUpMod.MOD_ID + "_confiscated_inventories";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of prisoner UUID to their confiscated inventory data
|
||||||
|
*/
|
||||||
|
private final Map<UUID, ConfiscatedData> confiscatedInventories =
|
||||||
|
new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class for a single confiscated inventory
|
||||||
|
*/
|
||||||
|
public static class ConfiscatedData {
|
||||||
|
|
||||||
|
public final UUID prisonerId;
|
||||||
|
public final CompoundTag inventoryNbt;
|
||||||
|
public final BlockPos chestPos;
|
||||||
|
public final UUID cellId;
|
||||||
|
public final long confiscatedTime;
|
||||||
|
|
||||||
|
public ConfiscatedData(
|
||||||
|
UUID prisonerId,
|
||||||
|
CompoundTag inventoryNbt,
|
||||||
|
BlockPos chestPos,
|
||||||
|
@Nullable UUID cellId,
|
||||||
|
long confiscatedTime
|
||||||
|
) {
|
||||||
|
this.prisonerId = prisonerId;
|
||||||
|
this.inventoryNbt = inventoryNbt;
|
||||||
|
this.chestPos = chestPos;
|
||||||
|
this.cellId = cellId;
|
||||||
|
this.confiscatedTime = confiscatedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompoundTag save() {
|
||||||
|
CompoundTag tag = new CompoundTag();
|
||||||
|
tag.putUUID("prisonerId", prisonerId);
|
||||||
|
tag.put("inventory", inventoryNbt);
|
||||||
|
tag.put("chestPos", NbtUtils.writeBlockPos(chestPos));
|
||||||
|
if (cellId != null) {
|
||||||
|
tag.putUUID("cellId", cellId);
|
||||||
|
}
|
||||||
|
tag.putLong("time", confiscatedTime);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConfiscatedData load(CompoundTag tag) {
|
||||||
|
UUID prisonerId = tag.getUUID("prisonerId");
|
||||||
|
CompoundTag inventoryNbt = tag.getCompound("inventory");
|
||||||
|
BlockPos chestPos = NbtUtils.readBlockPos(
|
||||||
|
tag.getCompound("chestPos")
|
||||||
|
);
|
||||||
|
UUID cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null;
|
||||||
|
long time = tag.getLong("time");
|
||||||
|
return new ConfiscatedData(
|
||||||
|
prisonerId,
|
||||||
|
inventoryNbt,
|
||||||
|
chestPos,
|
||||||
|
cellId,
|
||||||
|
time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConfiscatedInventoryRegistry() {}
|
||||||
|
|
||||||
|
// ==================== STATIC ACCESSORS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the registry for a server level.
|
||||||
|
*/
|
||||||
|
public static ConfiscatedInventoryRegistry get(ServerLevel level) {
|
||||||
|
return level
|
||||||
|
.getDataStorage()
|
||||||
|
.computeIfAbsent(
|
||||||
|
ConfiscatedInventoryRegistry::load,
|
||||||
|
ConfiscatedInventoryRegistry::new,
|
||||||
|
DATA_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CONFISCATION METHODS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confiscate a player's inventory.
|
||||||
|
*
|
||||||
|
* @param player The player whose inventory to confiscate
|
||||||
|
* @param chestPos The position of the LOOT chest
|
||||||
|
* @param cellId The cell ID (optional)
|
||||||
|
* @return true if confiscation was successful
|
||||||
|
*/
|
||||||
|
public boolean confiscate(
|
||||||
|
ServerPlayer player,
|
||||||
|
BlockPos chestPos,
|
||||||
|
@Nullable UUID cellId
|
||||||
|
) {
|
||||||
|
Inventory inventory = player.getInventory();
|
||||||
|
|
||||||
|
// CRITICAL FIX: Transaction safety - save record BEFORE any state changes
|
||||||
|
// This ensures that if the server crashes, we have a backup to restore from
|
||||||
|
|
||||||
|
// Save inventory to NBT (backup in case of issues)
|
||||||
|
CompoundTag inventoryNbt = savePlayerInventory(player);
|
||||||
|
|
||||||
|
// Create and persist record FIRST (before modifying game state)
|
||||||
|
ConfiscatedData data = new ConfiscatedData(
|
||||||
|
player.getUUID(),
|
||||||
|
inventoryNbt,
|
||||||
|
chestPos,
|
||||||
|
cellId,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
confiscatedInventories.put(player.getUUID(), data);
|
||||||
|
setDirty(); // Persist immediately before state changes
|
||||||
|
|
||||||
|
// Now attempt transfer to chest
|
||||||
|
boolean transferred = transferToChest(
|
||||||
|
player.serverLevel(),
|
||||||
|
chestPos,
|
||||||
|
inventory
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transferred) {
|
||||||
|
// Transfer failed - rollback: remove record and DO NOT clear inventory
|
||||||
|
confiscatedInventories.remove(player.getUUID());
|
||||||
|
setDirty(); // Persist the rollback
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.error(
|
||||||
|
"[ConfiscatedInventoryRegistry] Failed to transfer items for {} - items NOT confiscated, record rolled back",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer succeeded - now safe to clear inventory
|
||||||
|
inventory.clearContent();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[ConfiscatedInventoryRegistry] Confiscated inventory from {} (chest: {}, cell: {})",
|
||||||
|
player.getName().getString(),
|
||||||
|
chestPos.toShortString(),
|
||||||
|
cellId != null ? cellId.toString().substring(0, 8) : "none"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Final persist to save cleared inventory state
|
||||||
|
setDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dump a player's inventory to a chest WITHOUT creating a backup record.
|
||||||
|
* Used for daily labor returns - items gathered during work are transferred to camp storage.
|
||||||
|
* Does NOT clear the player's inventory - caller should clear labor tools separately.
|
||||||
|
*
|
||||||
|
* @param player The player whose inventory to dump
|
||||||
|
* @param chestPos The position of the chest
|
||||||
|
* @return true if transfer was successful
|
||||||
|
*/
|
||||||
|
public boolean dumpInventoryToChest(
|
||||||
|
ServerPlayer player,
|
||||||
|
BlockPos chestPos
|
||||||
|
) {
|
||||||
|
Inventory inventory = player.getInventory();
|
||||||
|
|
||||||
|
// Transfer items to chest
|
||||||
|
boolean transferred = transferToChest(
|
||||||
|
player.serverLevel(),
|
||||||
|
chestPos,
|
||||||
|
inventory
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transferred) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[ConfiscatedInventoryRegistry] Failed to dump labor inventory for {} - chest issue",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear player inventory (items are now in chest)
|
||||||
|
inventory.clearContent();
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[ConfiscatedInventoryRegistry] Dumped labor inventory from {} to chest at {}",
|
||||||
|
player.getName().getString(),
|
||||||
|
chestPos.toShortString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deposits items across multiple LOOT chests with smart rotation.
|
||||||
|
* Distributes items evenly to prevent single chest from filling too quickly.
|
||||||
|
*
|
||||||
|
* @param items List of items to deposit
|
||||||
|
* @param chestPositions List of chest positions to use (in priority order)
|
||||||
|
* @param level Server level
|
||||||
|
* @return Number of items successfully deposited (remainder dropped)
|
||||||
|
*/
|
||||||
|
public int depositItemsInChests(
|
||||||
|
List<ItemStack> items,
|
||||||
|
List<BlockPos> chestPositions,
|
||||||
|
ServerLevel level
|
||||||
|
) {
|
||||||
|
if (items.isEmpty() || chestPositions.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int deposited = 0;
|
||||||
|
List<ItemStack> overflow = new ArrayList<>();
|
||||||
|
|
||||||
|
// Try to deposit each item
|
||||||
|
for (ItemStack stack : items) {
|
||||||
|
if (stack.isEmpty()) continue;
|
||||||
|
|
||||||
|
boolean placed = false;
|
||||||
|
|
||||||
|
// Try all chests in order
|
||||||
|
for (BlockPos chestPos : chestPositions) {
|
||||||
|
BlockEntity be = level.getBlockEntity(chestPos);
|
||||||
|
if (!(be instanceof ChestBlockEntity chest)) continue;
|
||||||
|
|
||||||
|
// Try to stack with existing items first
|
||||||
|
for (int i = 0; i < chest.getContainerSize(); i++) {
|
||||||
|
ItemStack slot = chest.getItem(i);
|
||||||
|
|
||||||
|
// Stack with existing
|
||||||
|
if (
|
||||||
|
!slot.isEmpty() &&
|
||||||
|
ItemStack.isSameItemSameTags(slot, stack) &&
|
||||||
|
slot.getCount() < slot.getMaxStackSize()
|
||||||
|
) {
|
||||||
|
int spaceInSlot =
|
||||||
|
slot.getMaxStackSize() - slot.getCount();
|
||||||
|
int toAdd = Math.min(spaceInSlot, stack.getCount());
|
||||||
|
|
||||||
|
slot.grow(toAdd);
|
||||||
|
chest.setChanged();
|
||||||
|
stack.shrink(toAdd);
|
||||||
|
deposited += toAdd;
|
||||||
|
|
||||||
|
if (stack.isEmpty()) {
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placed) break;
|
||||||
|
|
||||||
|
// Try to place in empty slot
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
for (int i = 0; i < chest.getContainerSize(); i++) {
|
||||||
|
if (chest.getItem(i).isEmpty()) {
|
||||||
|
chest.setItem(i, stack.copy());
|
||||||
|
chest.setChanged();
|
||||||
|
deposited += stack.getCount();
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not placed, add to overflow
|
||||||
|
if (!placed && !stack.isEmpty()) {
|
||||||
|
overflow.add(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop overflow items at first chest location
|
||||||
|
if (!overflow.isEmpty() && !chestPositions.isEmpty()) {
|
||||||
|
BlockPos dropPos = chestPositions.get(0);
|
||||||
|
for (ItemStack stack : overflow) {
|
||||||
|
net.minecraft.world.entity.item.ItemEntity itemEntity =
|
||||||
|
new net.minecraft.world.entity.item.ItemEntity(
|
||||||
|
level,
|
||||||
|
dropPos.getX() + 0.5,
|
||||||
|
dropPos.getY() + 1.0,
|
||||||
|
dropPos.getZ() + 0.5,
|
||||||
|
stack
|
||||||
|
);
|
||||||
|
level.addFreshEntity(itemEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[ConfiscatedInventoryRegistry] {} items overflowed - dropped at {}",
|
||||||
|
overflow.stream().mapToInt(ItemStack::getCount).sum(),
|
||||||
|
dropPos.toShortString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deposited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save player inventory to NBT.
|
||||||
|
*/
|
||||||
|
private CompoundTag savePlayerInventory(ServerPlayer player) {
|
||||||
|
CompoundTag tag = new CompoundTag();
|
||||||
|
tag.put("Items", player.getInventory().save(new ListTag()));
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer inventory contents to a chest.
|
||||||
|
* Handles ALL inventory slots:
|
||||||
|
* - Slots 0-35: Main inventory (hotbar 0-8, backpack 9-35)
|
||||||
|
* - Slots 36-39: Armor (boots, leggings, chestplate, helmet)
|
||||||
|
* - Slot 40: Offhand
|
||||||
|
*/
|
||||||
|
private boolean transferToChest(
|
||||||
|
ServerLevel level,
|
||||||
|
BlockPos chestPos,
|
||||||
|
Inventory inventory
|
||||||
|
) {
|
||||||
|
// Find an existing chest near the LOOT marker position
|
||||||
|
BlockPos actualChestPos = findExistingChestNear(level, chestPos);
|
||||||
|
if (actualChestPos == null) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[ConfiscatedInventoryRegistry] No existing chest found near {} - structure may be damaged",
|
||||||
|
chestPos.toShortString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntity be = level.getBlockEntity(actualChestPos);
|
||||||
|
if (!(be instanceof Container chest)) {
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[ConfiscatedInventoryRegistry] Block at {} is not a container",
|
||||||
|
actualChestPos.toShortString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer ALL items including armor and offhand
|
||||||
|
int mainItems = 0;
|
||||||
|
int armorItems = 0;
|
||||||
|
int offhandItems = 0;
|
||||||
|
int droppedItems = 0;
|
||||||
|
|
||||||
|
// getContainerSize() returns 41: 36 main + 4 armor + 1 offhand
|
||||||
|
for (int i = 0; i < inventory.getContainerSize(); i++) {
|
||||||
|
ItemStack stack = inventory.getItem(i);
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
ItemStack remaining = addToContainer(chest, stack.copy());
|
||||||
|
if (remaining.isEmpty()) {
|
||||||
|
// Track which slot type was transferred
|
||||||
|
if (i < 36) {
|
||||||
|
mainItems++;
|
||||||
|
} else if (i < 40) {
|
||||||
|
armorItems++;
|
||||||
|
} else {
|
||||||
|
offhandItems++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Couldn't fit all items - drop them on the ground near the chest
|
||||||
|
// This prevents permanent item loss
|
||||||
|
net.minecraft.world.entity.item.ItemEntity itemEntity =
|
||||||
|
new net.minecraft.world.entity.item.ItemEntity(
|
||||||
|
level,
|
||||||
|
actualChestPos.getX() + 0.5,
|
||||||
|
actualChestPos.getY() + 1.0,
|
||||||
|
actualChestPos.getZ() + 0.5,
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
// Add slight random velocity to spread items
|
||||||
|
itemEntity.setDeltaMovement(
|
||||||
|
(level.random.nextDouble() - 0.5) * 0.2,
|
||||||
|
0.2,
|
||||||
|
(level.random.nextDouble() - 0.5) * 0.2
|
||||||
|
);
|
||||||
|
level.addFreshEntity(itemEntity);
|
||||||
|
droppedItems += remaining.getCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (droppedItems > 0) {
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[ConfiscatedInventoryRegistry] Dropped {} overflow items at {}",
|
||||||
|
droppedItems,
|
||||||
|
actualChestPos.toShortString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalTransferred = mainItems + armorItems + offhandItems;
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[ConfiscatedInventoryRegistry] Confiscated {} items (inventory: {}, armor: {}, offhand: {})",
|
||||||
|
totalTransferred,
|
||||||
|
mainItems,
|
||||||
|
armorItems,
|
||||||
|
offhandItems
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item stack to a container, returning any remainder.
|
||||||
|
*/
|
||||||
|
private ItemStack addToContainer(Container container, ItemStack stack) {
|
||||||
|
ItemStack remaining = stack.copy();
|
||||||
|
|
||||||
|
for (
|
||||||
|
int i = 0;
|
||||||
|
i < container.getContainerSize() && !remaining.isEmpty();
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
ItemStack slotStack = container.getItem(i);
|
||||||
|
|
||||||
|
if (slotStack.isEmpty()) {
|
||||||
|
container.setItem(i, remaining.copy());
|
||||||
|
remaining = ItemStack.EMPTY;
|
||||||
|
} else if (ItemStack.isSameItemSameTags(slotStack, remaining)) {
|
||||||
|
int space = slotStack.getMaxStackSize() - slotStack.getCount();
|
||||||
|
int toTransfer = Math.min(space, remaining.getCount());
|
||||||
|
if (toTransfer > 0) {
|
||||||
|
slotStack.grow(toTransfer);
|
||||||
|
remaining.shrink(toTransfer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.setChanged();
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing chest near the given position (typically a LOOT marker).
|
||||||
|
* The LOOT marker is placed ABOVE the physical chest in the structure,
|
||||||
|
* so we check below first, then at the position, then in a small radius.
|
||||||
|
*
|
||||||
|
* Does NOT spawn new chests - structures must have chests placed by their templates.
|
||||||
|
*
|
||||||
|
* @return The position of an existing chest, or null if none found
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private static BlockPos findExistingChestNear(
|
||||||
|
ServerLevel level,
|
||||||
|
BlockPos pos
|
||||||
|
) {
|
||||||
|
// Check below the marker (chest is usually under the LOOT marker)
|
||||||
|
BlockPos below = pos.below();
|
||||||
|
if (level.getBlockEntity(below) instanceof ChestBlockEntity) {
|
||||||
|
return below;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check at the marker position itself
|
||||||
|
if (level.getBlockEntity(pos) instanceof ChestBlockEntity) {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in a small radius (3 blocks)
|
||||||
|
for (int radius = 1; radius <= 3; radius++) {
|
||||||
|
for (int dx = -radius; dx <= radius; dx++) {
|
||||||
|
for (int dy = -2; dy <= 1; dy++) {
|
||||||
|
for (int dz = -radius; dz <= radius; dz++) {
|
||||||
|
if (dx == 0 && dy == 0 && dz == 0) continue;
|
||||||
|
|
||||||
|
BlockPos testPos = pos.offset(dx, dy, dz);
|
||||||
|
if (
|
||||||
|
level.getBlockEntity(testPos) instanceof
|
||||||
|
ChestBlockEntity
|
||||||
|
) {
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[ConfiscatedInventoryRegistry] Found existing chest at {} (offset from marker {})",
|
||||||
|
testPos.toShortString(),
|
||||||
|
pos.toShortString()
|
||||||
|
);
|
||||||
|
return testPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.warn(
|
||||||
|
"[ConfiscatedInventoryRegistry] No existing chest found near marker at {}",
|
||||||
|
pos.toShortString()
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RECOVERY METHODS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a player has confiscated inventory.
|
||||||
|
*/
|
||||||
|
public boolean hasConfiscatedInventory(UUID playerId) {
|
||||||
|
return confiscatedInventories.containsKey(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get confiscated data for a player.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public ConfiscatedData getConfiscatedData(UUID playerId) {
|
||||||
|
return confiscatedInventories.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the chest position for a player's confiscated inventory.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public BlockPos getChestPosition(UUID playerId) {
|
||||||
|
ConfiscatedData data = confiscatedInventories.get(playerId);
|
||||||
|
return data != null ? data.chestPos : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a player's confiscated inventory from the NBT backup.
|
||||||
|
* This gives items directly to the player, bypassing the chest.
|
||||||
|
*
|
||||||
|
* @param player The player to restore inventory to
|
||||||
|
* @return true if restoration was successful, false if no confiscated data found
|
||||||
|
*/
|
||||||
|
public boolean restoreInventory(ServerPlayer player) {
|
||||||
|
ConfiscatedData data = confiscatedInventories.get(player.getUUID());
|
||||||
|
if (data == null) {
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[ConfiscatedInventoryRegistry] No confiscated inventory for {}",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load inventory from NBT backup
|
||||||
|
if (data.inventoryNbt.contains("Items")) {
|
||||||
|
ListTag items = data.inventoryNbt.getList(
|
||||||
|
"Items",
|
||||||
|
Tag.TAG_COMPOUND
|
||||||
|
);
|
||||||
|
player.getInventory().load(items);
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[ConfiscatedInventoryRegistry] Restored {} inventory slots to {}",
|
||||||
|
items.size(),
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the confiscation record
|
||||||
|
confiscatedInventories.remove(player.getUUID());
|
||||||
|
setDirty();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the confiscation record for a player without restoring items.
|
||||||
|
* Used when the player has already retrieved items from the chest manually.
|
||||||
|
*
|
||||||
|
* @param playerId The player's UUID
|
||||||
|
*/
|
||||||
|
public void clearConfiscationRecord(UUID playerId) {
|
||||||
|
if (confiscatedInventories.remove(playerId) != null) {
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[ConfiscatedInventoryRegistry] Cleared confiscation record for {}",
|
||||||
|
playerId.toString().substring(0, 8)
|
||||||
|
);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SERIALIZATION ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompoundTag save(CompoundTag tag) {
|
||||||
|
ListTag list = new ListTag();
|
||||||
|
for (ConfiscatedData data : confiscatedInventories.values()) {
|
||||||
|
list.add(data.save());
|
||||||
|
}
|
||||||
|
tag.put("confiscated", list);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConfiscatedInventoryRegistry load(CompoundTag tag) {
|
||||||
|
ConfiscatedInventoryRegistry registry =
|
||||||
|
new ConfiscatedInventoryRegistry();
|
||||||
|
|
||||||
|
if (tag.contains("confiscated")) {
|
||||||
|
ListTag list = tag.getList("confiscated", Tag.TAG_COMPOUND);
|
||||||
|
for (int i = 0; i < list.size(); i++) {
|
||||||
|
ConfiscatedData data = ConfiscatedData.load(
|
||||||
|
list.getCompound(i)
|
||||||
|
);
|
||||||
|
registry.confiscatedInventories.put(data.prisonerId, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[ConfiscatedInventoryRegistry] Loaded {} confiscated inventory records",
|
||||||
|
registry.confiscatedInventories.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
407
src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java
Normal file
407
src/main/java/com/tiedup/remake/cells/FloodFillAlgorithm.java
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.*;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS flood-fill algorithm for detecting enclosed rooms around a Cell Core.
|
||||||
|
*
|
||||||
|
* Scans outward from air neighbors of the Core block, treating solid blocks
|
||||||
|
* (including the Core itself) as walls. Picks the smallest successful fill
|
||||||
|
* as the cell interior (most likely the room, not the hallway).
|
||||||
|
*/
|
||||||
|
public final class FloodFillAlgorithm {
|
||||||
|
|
||||||
|
static final int MAX_VOLUME = 1200;
|
||||||
|
static final int MIN_VOLUME = 2;
|
||||||
|
static final int MAX_X = 12;
|
||||||
|
static final int MAX_Y = 8;
|
||||||
|
static final int MAX_Z = 12;
|
||||||
|
|
||||||
|
private FloodFillAlgorithm() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try flood-fill from each air neighbor of the Core position.
|
||||||
|
* Pick the smallest successful fill (= most likely the cell, not the hallway).
|
||||||
|
* If none succeed, return a failure result.
|
||||||
|
*/
|
||||||
|
public static FloodFillResult tryFill(Level level, BlockPos corePos) {
|
||||||
|
Set<BlockPos> bestInterior = null;
|
||||||
|
Direction bestDirection = null;
|
||||||
|
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos neighbor = corePos.relative(dir);
|
||||||
|
BlockState neighborState = level.getBlockState(neighbor);
|
||||||
|
|
||||||
|
if (!isPassable(neighborState)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<BlockPos> interior = bfs(level, neighbor, corePos);
|
||||||
|
if (interior == null) {
|
||||||
|
// Overflow or out of bounds — this direction opens to the outside
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interior.size() < MIN_VOLUME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestInterior == null || interior.size() < bestInterior.size()) {
|
||||||
|
bestInterior = interior;
|
||||||
|
bestDirection = dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestInterior == null) {
|
||||||
|
// No direction produced a valid fill — check why
|
||||||
|
// Try again to determine the most helpful error message
|
||||||
|
boolean anyAir = false;
|
||||||
|
boolean tooLarge = false;
|
||||||
|
boolean tooSmall = false;
|
||||||
|
boolean outOfBounds = false;
|
||||||
|
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos neighbor = corePos.relative(dir);
|
||||||
|
BlockState neighborState = level.getBlockState(neighbor);
|
||||||
|
if (!isPassable(neighborState)) continue;
|
||||||
|
anyAir = true;
|
||||||
|
|
||||||
|
Set<BlockPos> result = bfsDiagnostic(level, neighbor, corePos);
|
||||||
|
if (result == null) {
|
||||||
|
// Overflowed — could be not enclosed or too large
|
||||||
|
tooLarge = true;
|
||||||
|
} else if (result.size() < MIN_VOLUME) {
|
||||||
|
tooSmall = true;
|
||||||
|
} else {
|
||||||
|
outOfBounds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyAir) {
|
||||||
|
return FloodFillResult.failure(
|
||||||
|
"msg.tiedup.cell_core.not_enclosed"
|
||||||
|
);
|
||||||
|
} else if (tooLarge) {
|
||||||
|
// Could be open to outside or genuinely too large
|
||||||
|
return FloodFillResult.failure(
|
||||||
|
"msg.tiedup.cell_core.not_enclosed"
|
||||||
|
);
|
||||||
|
} else if (outOfBounds) {
|
||||||
|
return FloodFillResult.failure(
|
||||||
|
"msg.tiedup.cell_core.out_of_bounds"
|
||||||
|
);
|
||||||
|
} else if (tooSmall) {
|
||||||
|
return FloodFillResult.failure(
|
||||||
|
"msg.tiedup.cell_core.too_small"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return FloodFillResult.failure(
|
||||||
|
"msg.tiedup.cell_core.not_enclosed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build walls set
|
||||||
|
Set<BlockPos> walls = findWalls(level, bestInterior, corePos);
|
||||||
|
|
||||||
|
// Detect features
|
||||||
|
List<BlockPos> beds = new ArrayList<>();
|
||||||
|
List<BlockPos> petBeds = new ArrayList<>();
|
||||||
|
List<BlockPos> anchors = new ArrayList<>();
|
||||||
|
List<BlockPos> doors = new ArrayList<>();
|
||||||
|
List<BlockPos> linkedRedstone = new ArrayList<>();
|
||||||
|
detectFeatures(
|
||||||
|
level,
|
||||||
|
bestInterior,
|
||||||
|
walls,
|
||||||
|
beds,
|
||||||
|
petBeds,
|
||||||
|
anchors,
|
||||||
|
doors,
|
||||||
|
linkedRedstone
|
||||||
|
);
|
||||||
|
|
||||||
|
return FloodFillResult.success(
|
||||||
|
bestInterior,
|
||||||
|
walls,
|
||||||
|
bestDirection,
|
||||||
|
beds,
|
||||||
|
petBeds,
|
||||||
|
anchors,
|
||||||
|
doors,
|
||||||
|
linkedRedstone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS from start position, treating corePos and solid blocks as walls.
|
||||||
|
*
|
||||||
|
* @return The set of interior (passable) positions, or null if the fill
|
||||||
|
* overflowed MAX_VOLUME or exceeded MAX bounds.
|
||||||
|
*/
|
||||||
|
private static Set<BlockPos> bfs(
|
||||||
|
Level level,
|
||||||
|
BlockPos start,
|
||||||
|
BlockPos corePos
|
||||||
|
) {
|
||||||
|
Set<BlockPos> visited = new HashSet<>();
|
||||||
|
Queue<BlockPos> queue = new ArrayDeque<>();
|
||||||
|
|
||||||
|
visited.add(start);
|
||||||
|
queue.add(start);
|
||||||
|
|
||||||
|
int minX = start.getX(),
|
||||||
|
maxX = start.getX();
|
||||||
|
int minY = start.getY(),
|
||||||
|
maxY = start.getY();
|
||||||
|
int minZ = start.getZ(),
|
||||||
|
maxZ = start.getZ();
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
BlockPos current = queue.poll();
|
||||||
|
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos next = current.relative(dir);
|
||||||
|
|
||||||
|
if (next.equals(corePos)) {
|
||||||
|
// Core is always treated as wall
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.contains(next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat unloaded chunks as walls to avoid synchronous chunk loading
|
||||||
|
if (!level.isLoaded(next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockState state = level.getBlockState(next);
|
||||||
|
if (!isPassable(state)) {
|
||||||
|
// Solid block = wall, don't expand
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(next);
|
||||||
|
|
||||||
|
// Check volume
|
||||||
|
if (visited.size() > MAX_VOLUME) {
|
||||||
|
return null; // Too large or not enclosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update bounds
|
||||||
|
minX = Math.min(minX, next.getX());
|
||||||
|
maxX = Math.max(maxX, next.getX());
|
||||||
|
minY = Math.min(minY, next.getY());
|
||||||
|
maxY = Math.max(maxY, next.getY());
|
||||||
|
minZ = Math.min(minZ, next.getZ());
|
||||||
|
maxZ = Math.max(maxZ, next.getZ());
|
||||||
|
|
||||||
|
// Check dimensional bounds
|
||||||
|
if (
|
||||||
|
(maxX - minX + 1) > MAX_X ||
|
||||||
|
(maxY - minY + 1) > MAX_Y ||
|
||||||
|
(maxZ - minZ + 1) > MAX_Z
|
||||||
|
) {
|
||||||
|
return null; // Exceeds max dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic BFS: same as bfs() but returns the set even on bounds overflow
|
||||||
|
* (returns null only on volume overflow). Used to determine error messages.
|
||||||
|
*/
|
||||||
|
private static Set<BlockPos> bfsDiagnostic(
|
||||||
|
Level level,
|
||||||
|
BlockPos start,
|
||||||
|
BlockPos corePos
|
||||||
|
) {
|
||||||
|
Set<BlockPos> visited = new HashSet<>();
|
||||||
|
Queue<BlockPos> queue = new ArrayDeque<>();
|
||||||
|
|
||||||
|
visited.add(start);
|
||||||
|
queue.add(start);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
BlockPos current = queue.poll();
|
||||||
|
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos next = current.relative(dir);
|
||||||
|
|
||||||
|
if (next.equals(corePos) || visited.contains(next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat unloaded chunks as walls to avoid synchronous chunk loading
|
||||||
|
if (!level.isLoaded(next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockState state = level.getBlockState(next);
|
||||||
|
if (!isPassable(state)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(next);
|
||||||
|
|
||||||
|
if (visited.size() > MAX_VOLUME) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all solid blocks adjacent to the interior set (the walls of the cell).
|
||||||
|
* The Core block itself is always included as a wall.
|
||||||
|
*/
|
||||||
|
private static Set<BlockPos> findWalls(
|
||||||
|
Level level,
|
||||||
|
Set<BlockPos> interior,
|
||||||
|
BlockPos corePos
|
||||||
|
) {
|
||||||
|
Set<BlockPos> walls = new HashSet<>();
|
||||||
|
walls.add(corePos);
|
||||||
|
|
||||||
|
for (BlockPos pos : interior) {
|
||||||
|
for (Direction dir : Direction.values()) {
|
||||||
|
BlockPos neighbor = pos.relative(dir);
|
||||||
|
if (!interior.contains(neighbor) && !neighbor.equals(corePos)) {
|
||||||
|
// This is a solid boundary block
|
||||||
|
walls.add(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan interior and wall blocks to detect notable features.
|
||||||
|
*/
|
||||||
|
private static void detectFeatures(
|
||||||
|
Level level,
|
||||||
|
Set<BlockPos> interior,
|
||||||
|
Set<BlockPos> walls,
|
||||||
|
List<BlockPos> beds,
|
||||||
|
List<BlockPos> petBeds,
|
||||||
|
List<BlockPos> anchors,
|
||||||
|
List<BlockPos> doors,
|
||||||
|
List<BlockPos> linkedRedstone
|
||||||
|
) {
|
||||||
|
// Scan interior for beds and pet beds
|
||||||
|
for (BlockPos pos : interior) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
Block block = state.getBlock();
|
||||||
|
|
||||||
|
if (block instanceof BedBlock) {
|
||||||
|
// Only count the HEAD part to avoid double-counting (beds are 2 blocks)
|
||||||
|
if (
|
||||||
|
state.getValue(BedBlock.PART) ==
|
||||||
|
net.minecraft.world.level.block.state.properties.BedPart.HEAD
|
||||||
|
) {
|
||||||
|
beds.add(pos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mod's pet bed block
|
||||||
|
if (block instanceof com.tiedup.remake.v2.blocks.PetBedBlock) {
|
||||||
|
petBeds.add(pos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan walls for doors, redstone components, and anchors
|
||||||
|
for (BlockPos pos : walls) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
Block block = state.getBlock();
|
||||||
|
|
||||||
|
// Doors, trapdoors, fence gates
|
||||||
|
if (block instanceof DoorBlock) {
|
||||||
|
// Only count the lower half to avoid double-counting
|
||||||
|
if (
|
||||||
|
state.getValue(DoorBlock.HALF) ==
|
||||||
|
net.minecraft.world.level.block.state.properties.DoubleBlockHalf.LOWER
|
||||||
|
) {
|
||||||
|
doors.add(pos.immutable());
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
block instanceof TrapDoorBlock ||
|
||||||
|
block instanceof FenceGateBlock
|
||||||
|
) {
|
||||||
|
doors.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain blocks as anchors
|
||||||
|
if (block instanceof ChainBlock) {
|
||||||
|
anchors.add(pos.immutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons and levers as linked redstone
|
||||||
|
if (block instanceof ButtonBlock || block instanceof LeverBlock) {
|
||||||
|
linkedRedstone.add(pos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for buttons/levers on the interior side adjacent to walls
|
||||||
|
for (BlockPos pos : interior) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
Block block = state.getBlock();
|
||||||
|
|
||||||
|
if (block instanceof ButtonBlock || block instanceof LeverBlock) {
|
||||||
|
linkedRedstone.add(pos.immutable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a block state is passable for flood-fill purposes.
|
||||||
|
*
|
||||||
|
* Air and non-solid blocks (torches, carpets, flowers, signs, etc.) are passable.
|
||||||
|
* Closed doors block the fill (treated as walls). Open doors let fill through.
|
||||||
|
* Glass, bars, fences are solid → treated as wall.
|
||||||
|
*/
|
||||||
|
private static boolean isPassable(BlockState state) {
|
||||||
|
if (state.isAir()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Block block = state.getBlock();
|
||||||
|
|
||||||
|
// Doors are always treated as walls for flood-fill (detected as features separately).
|
||||||
|
// This prevents the fill from leaking through open doors.
|
||||||
|
if (
|
||||||
|
block instanceof DoorBlock ||
|
||||||
|
block instanceof TrapDoorBlock ||
|
||||||
|
block instanceof FenceGateBlock
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beds are interior furniture, not walls.
|
||||||
|
// BedBlock.isSolid() returns true in 1.20.1 which would misclassify them as walls,
|
||||||
|
// preventing detectFeatures() from finding them (it only scans interior for beds).
|
||||||
|
if (block instanceof BedBlock) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-solid decorative blocks are passable
|
||||||
|
// This covers torches, carpets, flowers, signs, pressure plates, etc.
|
||||||
|
return !state.isSolid();
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/main/java/com/tiedup/remake/cells/FloodFillResult.java
Normal file
140
src/main/java/com/tiedup/remake/cells/FloodFillResult.java
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable result container returned by the flood-fill algorithm.
|
||||||
|
*
|
||||||
|
* Either a success (with geometry and detected features) or a failure (with an error translation key).
|
||||||
|
*/
|
||||||
|
public class FloodFillResult {
|
||||||
|
|
||||||
|
private final boolean success;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final String errorKey;
|
||||||
|
|
||||||
|
// Geometry
|
||||||
|
private final Set<BlockPos> interior;
|
||||||
|
private final Set<BlockPos> walls;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final Direction interiorFace;
|
||||||
|
|
||||||
|
// Auto-detected features
|
||||||
|
private final List<BlockPos> beds;
|
||||||
|
private final List<BlockPos> petBeds;
|
||||||
|
private final List<BlockPos> anchors;
|
||||||
|
private final List<BlockPos> doors;
|
||||||
|
private final List<BlockPos> linkedRedstone;
|
||||||
|
|
||||||
|
private FloodFillResult(
|
||||||
|
boolean success,
|
||||||
|
@Nullable String errorKey,
|
||||||
|
Set<BlockPos> interior,
|
||||||
|
Set<BlockPos> walls,
|
||||||
|
@Nullable Direction interiorFace,
|
||||||
|
List<BlockPos> beds,
|
||||||
|
List<BlockPos> petBeds,
|
||||||
|
List<BlockPos> anchors,
|
||||||
|
List<BlockPos> doors,
|
||||||
|
List<BlockPos> linkedRedstone
|
||||||
|
) {
|
||||||
|
this.success = success;
|
||||||
|
this.errorKey = errorKey;
|
||||||
|
this.interior = Collections.unmodifiableSet(interior);
|
||||||
|
this.walls = Collections.unmodifiableSet(walls);
|
||||||
|
this.interiorFace = interiorFace;
|
||||||
|
this.beds = Collections.unmodifiableList(beds);
|
||||||
|
this.petBeds = Collections.unmodifiableList(petBeds);
|
||||||
|
this.anchors = Collections.unmodifiableList(anchors);
|
||||||
|
this.doors = Collections.unmodifiableList(doors);
|
||||||
|
this.linkedRedstone = Collections.unmodifiableList(linkedRedstone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FloodFillResult success(
|
||||||
|
Set<BlockPos> interior,
|
||||||
|
Set<BlockPos> walls,
|
||||||
|
Direction interiorFace,
|
||||||
|
List<BlockPos> beds,
|
||||||
|
List<BlockPos> petBeds,
|
||||||
|
List<BlockPos> anchors,
|
||||||
|
List<BlockPos> doors,
|
||||||
|
List<BlockPos> linkedRedstone
|
||||||
|
) {
|
||||||
|
return new FloodFillResult(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
interior,
|
||||||
|
walls,
|
||||||
|
interiorFace,
|
||||||
|
beds,
|
||||||
|
petBeds,
|
||||||
|
anchors,
|
||||||
|
doors,
|
||||||
|
linkedRedstone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FloodFillResult failure(String errorKey) {
|
||||||
|
return new FloodFillResult(
|
||||||
|
false,
|
||||||
|
errorKey,
|
||||||
|
Collections.emptySet(),
|
||||||
|
Collections.emptySet(),
|
||||||
|
null,
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters ---
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getErrorKey() {
|
||||||
|
return errorKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<BlockPos> getInterior() {
|
||||||
|
return interior;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<BlockPos> getWalls() {
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Direction getInteriorFace() {
|
||||||
|
return interiorFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getBeds() {
|
||||||
|
return beds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getPetBeds() {
|
||||||
|
return petBeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getAnchors() {
|
||||||
|
return anchors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getDoors() {
|
||||||
|
return doors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockPos> getLinkedRedstone() {
|
||||||
|
return linkedRedstone;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/main/java/com/tiedup/remake/cells/MarkerType.java
Normal file
161
src/main/java/com/tiedup/remake/cells/MarkerType.java
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum defining the types of markers used in the cell system.
|
||||||
|
*
|
||||||
|
* Phase: Kidnapper Revamp - Cell System
|
||||||
|
*
|
||||||
|
* Markers are invisible points placed by structure builders to define
|
||||||
|
* functional areas within kidnapper hideouts.
|
||||||
|
*/
|
||||||
|
public enum MarkerType {
|
||||||
|
// ==================== CELL MARKERS (V1 legacy — kept for retrocompat) ====================
|
||||||
|
|
||||||
|
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
|
||||||
|
@Deprecated
|
||||||
|
WALL("wall", true),
|
||||||
|
|
||||||
|
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
|
||||||
|
@Deprecated
|
||||||
|
ANCHOR("anchor", true),
|
||||||
|
|
||||||
|
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
|
||||||
|
@Deprecated
|
||||||
|
BED("bed", true),
|
||||||
|
|
||||||
|
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
|
||||||
|
@Deprecated
|
||||||
|
DOOR("door", true),
|
||||||
|
|
||||||
|
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
|
||||||
|
@Deprecated
|
||||||
|
DELIVERY("delivery", true),
|
||||||
|
|
||||||
|
// ==================== STRUCTURE MARKERS (Admin Wand) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrance marker - Main entry point to the structure.
|
||||||
|
* Used for AI pathfinding and player release point.
|
||||||
|
*/
|
||||||
|
ENTRANCE("entrance", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patrol marker - Waypoint for kidnapper AI patrol routes.
|
||||||
|
* Guards will walk between these points.
|
||||||
|
*/
|
||||||
|
PATROL("patrol", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loot marker - Position for loot chests in structures.
|
||||||
|
* Used for confiscated inventory storage.
|
||||||
|
*/
|
||||||
|
LOOT("loot", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawner marker - Position for kidnapper spawns.
|
||||||
|
* Kidnappers respawn at these points.
|
||||||
|
*/
|
||||||
|
SPAWNER("spawner", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trader spawn marker - Position for SlaveTrader spawn.
|
||||||
|
* Only spawns once when structure generates.
|
||||||
|
*/
|
||||||
|
TRADER_SPAWN("trader_spawn", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maid spawn marker - Position for Maid spawn.
|
||||||
|
* Spawns linked to the nearest trader.
|
||||||
|
*/
|
||||||
|
MAID_SPAWN("maid_spawn", false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merchant spawn marker - Position for Merchant spawn.
|
||||||
|
* Spawns a merchant NPC that can trade/buy items.
|
||||||
|
*/
|
||||||
|
MERCHANT_SPAWN("merchant_spawn", false);
|
||||||
|
|
||||||
|
private final String serializedName;
|
||||||
|
private final boolean cellMarker;
|
||||||
|
|
||||||
|
MarkerType(String serializedName, boolean cellMarker) {
|
||||||
|
this.serializedName = serializedName;
|
||||||
|
this.cellMarker = cellMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the serialized name for NBT/network storage.
|
||||||
|
*/
|
||||||
|
public String getSerializedName() {
|
||||||
|
return serializedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a cell-level marker (vs structure-level).
|
||||||
|
*/
|
||||||
|
public boolean isCellMarker() {
|
||||||
|
return cellMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a structure-level marker.
|
||||||
|
*/
|
||||||
|
public boolean isStructureMarker() {
|
||||||
|
return !cellMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this marker type should be linked to a cell's positions.
|
||||||
|
* This includes WALL, ANCHOR, BED, DOOR - positions that define cell structure.
|
||||||
|
*/
|
||||||
|
public boolean isLinkedPosition() {
|
||||||
|
return cellMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a MarkerType from its serialized name.
|
||||||
|
*
|
||||||
|
* @param name The serialized name
|
||||||
|
* @return The MarkerType, or WALL as default
|
||||||
|
*/
|
||||||
|
public static MarkerType fromString(String name) {
|
||||||
|
for (MarkerType type : values()) {
|
||||||
|
if (type.serializedName.equalsIgnoreCase(name)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ENTRANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next STRUCTURE marker type (for Admin Wand).
|
||||||
|
* Cycles: ENTRANCE -> PATROL -> LOOT -> SPAWNER -> TRADER_SPAWN -> MAID_SPAWN -> MERCHANT_SPAWN -> ENTRANCE
|
||||||
|
*/
|
||||||
|
public MarkerType nextStructureType() {
|
||||||
|
return switch (this) {
|
||||||
|
case ENTRANCE -> PATROL;
|
||||||
|
case PATROL -> LOOT;
|
||||||
|
case LOOT -> SPAWNER;
|
||||||
|
case SPAWNER -> TRADER_SPAWN;
|
||||||
|
case TRADER_SPAWN -> MAID_SPAWN;
|
||||||
|
case MAID_SPAWN -> MERCHANT_SPAWN;
|
||||||
|
case MERCHANT_SPAWN -> ENTRANCE;
|
||||||
|
default -> ENTRANCE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all structure marker types.
|
||||||
|
*/
|
||||||
|
public static MarkerType[] structureTypes() {
|
||||||
|
return new MarkerType[] {
|
||||||
|
ENTRANCE,
|
||||||
|
PATROL,
|
||||||
|
LOOT,
|
||||||
|
SPAWNER,
|
||||||
|
TRADER_SPAWN,
|
||||||
|
MAID_SPAWN,
|
||||||
|
MERCHANT_SPAWN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/com/tiedup/remake/cells/SelectionMode.java
Normal file
12
src/main/java/com/tiedup/remake/cells/SelectionMode.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tiedup.remake.cells;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection modes for the Cell Core right-click menu.
|
||||||
|
* When a player clicks "Set Spawn", "Set Delivery", or "Set Disguise",
|
||||||
|
* they enter a selection mode where their next block click is captured.
|
||||||
|
*/
|
||||||
|
public enum SelectionMode {
|
||||||
|
SET_SPAWN,
|
||||||
|
SET_DELIVERY,
|
||||||
|
SET_DISGUISE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.tiedup.remake.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.GenericBind;
|
||||||
|
import com.tiedup.remake.items.base.BindVariant;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.PlayerModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.RenderType;
|
||||||
|
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
|
||||||
|
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderArmEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders mittens on the player's arms in first-person view.
|
||||||
|
*
|
||||||
|
* Uses RenderArmEvent which fires specifically when a player's arm
|
||||||
|
* is being rendered in first person. This is more targeted than RenderHandEvent.
|
||||||
|
*
|
||||||
|
* @see <a href="https://nekoyue.github.io/ForgeJavaDocs-NG/javadoc/1.18.2/net/minecraftforge/client/event/RenderArmEvent.html">RenderArmEvent Documentation</a>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class FirstPersonMittensRenderer {
|
||||||
|
|
||||||
|
private static final ResourceLocation MITTENS_TEXTURE =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
TiedUpMod.MOD_ID,
|
||||||
|
"textures/models/bondage/mittens/mittens.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render mittens overlay on the player's arm in first-person view.
|
||||||
|
*
|
||||||
|
* This event fires after the arm is set up for rendering but we can add
|
||||||
|
* our own rendering on top of it.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderArm(RenderArmEvent event) {
|
||||||
|
AbstractClientPlayer player = event.getPlayer();
|
||||||
|
|
||||||
|
// Get player's bind state
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) return;
|
||||||
|
|
||||||
|
// If tied up, arms are hidden by FirstPersonHandHideHandler - don't render mittens
|
||||||
|
if (state.isTiedUp()) return;
|
||||||
|
|
||||||
|
// Check if player has mittens
|
||||||
|
if (!state.hasMittens()) return;
|
||||||
|
|
||||||
|
// Hide mittens when player is in a wrap or latex sack (hands are covered)
|
||||||
|
if (isBindHidingMittens(player)) return;
|
||||||
|
|
||||||
|
// Render mittens on this arm
|
||||||
|
renderMittensOnArm(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the mittens overlay on the arm.
|
||||||
|
*/
|
||||||
|
private static void renderMittensOnArm(RenderArmEvent event) {
|
||||||
|
PoseStack poseStack = event.getPoseStack();
|
||||||
|
MultiBufferSource buffer = event.getMultiBufferSource();
|
||||||
|
int packedLight = event.getPackedLight();
|
||||||
|
AbstractClientPlayer player = event.getPlayer();
|
||||||
|
HumanoidArm arm = event.getArm();
|
||||||
|
|
||||||
|
// Get the player's model to access the arm ModelPart
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
var renderer = mc.getEntityRenderDispatcher().getRenderer(player);
|
||||||
|
if (!(renderer instanceof PlayerRenderer playerRenderer)) return;
|
||||||
|
|
||||||
|
PlayerModel<AbstractClientPlayer> playerModel =
|
||||||
|
playerRenderer.getModel();
|
||||||
|
|
||||||
|
poseStack.pushPose();
|
||||||
|
|
||||||
|
// Get the appropriate arm from the player model
|
||||||
|
ModelPart armPart = (arm == HumanoidArm.RIGHT)
|
||||||
|
? playerModel.rightArm
|
||||||
|
: playerModel.leftArm;
|
||||||
|
ModelPart sleevePart = (arm == HumanoidArm.RIGHT)
|
||||||
|
? playerModel.rightSleeve
|
||||||
|
: playerModel.leftSleeve;
|
||||||
|
|
||||||
|
// The arm is already positioned by the game's first-person renderer
|
||||||
|
// We just need to render our mittens texture on top
|
||||||
|
|
||||||
|
// Use a slightly scaled version to appear on top (avoid z-fighting)
|
||||||
|
poseStack.scale(1.001F, 1.001F, 1.001F);
|
||||||
|
|
||||||
|
// Render the arm with mittens texture
|
||||||
|
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||||
|
RenderType.entitySolid(MITTENS_TEXTURE)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render the arm part with mittens texture
|
||||||
|
armPart.render(
|
||||||
|
poseStack,
|
||||||
|
vertexConsumer,
|
||||||
|
packedLight,
|
||||||
|
OverlayTexture.NO_OVERLAY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also render the sleeve part if visible
|
||||||
|
if (sleevePart.visible) {
|
||||||
|
sleevePart.render(
|
||||||
|
poseStack,
|
||||||
|
vertexConsumer,
|
||||||
|
packedLight,
|
||||||
|
OverlayTexture.NO_OVERLAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
poseStack.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the player's current bind variant hides mittens.
|
||||||
|
* WRAP and LATEX_SACK cover the entire body including hands.
|
||||||
|
*/
|
||||||
|
private static boolean isBindHidingMittens(AbstractClientPlayer player) {
|
||||||
|
net.minecraft.world.item.ItemStack bindStack =
|
||||||
|
V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS);
|
||||||
|
if (bindStack.isEmpty()) return false;
|
||||||
|
if (bindStack.getItem() instanceof GenericBind bind) {
|
||||||
|
BindVariant variant = bind.getVariant();
|
||||||
|
return (
|
||||||
|
variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
467
src/main/java/com/tiedup/remake/client/ModKeybindings.java
Normal file
467
src/main/java/com/tiedup/remake/client/ModKeybindings.java
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
package com.tiedup.remake.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
|
||||||
|
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
|
||||||
|
import com.tiedup.remake.core.ModConfig;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.ILockable;
|
||||||
|
import com.tiedup.remake.items.base.ItemCollar;
|
||||||
|
import com.tiedup.remake.network.ModNetwork;
|
||||||
|
import com.tiedup.remake.network.action.PacketForceSeatModifier;
|
||||||
|
import com.tiedup.remake.network.action.PacketStruggle;
|
||||||
|
import com.tiedup.remake.network.action.PacketTighten;
|
||||||
|
import com.tiedup.remake.network.bounty.PacketRequestBounties;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||||
|
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
|
import net.minecraft.client.KeyMapping;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Manages key mappings and sends packets to server when keys are pressed.
|
||||||
|
*
|
||||||
|
* Based on original KeyBindings from 1.12.2
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class ModKeybindings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key category for TiedUp keybindings
|
||||||
|
*/
|
||||||
|
private static final String CATEGORY = "key.categories.tiedup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Struggle keybinding - Press to struggle against binds
|
||||||
|
* Default: R key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping STRUGGLE_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.struggle", // Translation key
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_R, // Default key: R
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjustment screen keybinding - Open item adjustment screen
|
||||||
|
* Default: K key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping ADJUSTMENT_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.adjustment_screen",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_K, // Default key: K
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bondage inventory keybinding - Open bondage inventory screen
|
||||||
|
* Default: J key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping INVENTORY_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.bondage_inventory",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_J, // Default key: J
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slave management keybinding - Open slave management dashboard
|
||||||
|
* Default: L key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping SLAVE_MANAGEMENT_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.slave_management",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_L, // Default key: L
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounty list keybinding - Open bounty list screen
|
||||||
|
* Default: B key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping BOUNTY_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.bounties",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_B, // Default key: B
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force seat keybinding - Hold to force captive on/off vehicles
|
||||||
|
* Default: Left ALT key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping FORCE_SEAT_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.force_seat",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_LALT, // Default key: Left ALT
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tighten bind keybinding - Tighten binds on looked-at target
|
||||||
|
* Default: T key
|
||||||
|
*/
|
||||||
|
public static final KeyMapping TIGHTEN_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.tighten",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_T, // Default key: T
|
||||||
|
CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Track last sent state to avoid spamming packets */
|
||||||
|
private static boolean lastForceSeatState = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Force Seat key is currently pressed.
|
||||||
|
*/
|
||||||
|
public static boolean isForceSeatPressed() {
|
||||||
|
return FORCE_SEAT_KEY.isDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register keybindings.
|
||||||
|
* Called during mod initialization (MOD bus).
|
||||||
|
*
|
||||||
|
* @param event The registration event
|
||||||
|
*/
|
||||||
|
public static void register(RegisterKeyMappingsEvent event) {
|
||||||
|
event.register(STRUGGLE_KEY);
|
||||||
|
event.register(ADJUSTMENT_KEY);
|
||||||
|
event.register(INVENTORY_KEY);
|
||||||
|
event.register(SLAVE_MANAGEMENT_KEY);
|
||||||
|
event.register(BOUNTY_KEY);
|
||||||
|
event.register(FORCE_SEAT_KEY);
|
||||||
|
event.register(TIGHTEN_KEY);
|
||||||
|
TiedUpMod.LOGGER.info("Registered {} keybindings", 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the vanilla movement keybind for a given direction index.
|
||||||
|
* Uses Minecraft's movement keys so AZERTY/QWERTY is already configured.
|
||||||
|
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
|
||||||
|
* @return The keybind or null if invalid index
|
||||||
|
*/
|
||||||
|
public static KeyMapping getStruggleDirectionKey(int index) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.options == null) return null;
|
||||||
|
return switch (index) {
|
||||||
|
case 0 -> mc.options.keyUp; // Forward (W/Z)
|
||||||
|
case 1 -> mc.options.keyLeft; // Strafe Left (A/Q)
|
||||||
|
case 2 -> mc.options.keyDown; // Back (S)
|
||||||
|
case 3 -> mc.options.keyRight; // Strafe Right (D)
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a keycode matches any vanilla movement keybind.
|
||||||
|
* @param keyCode The GLFW key code
|
||||||
|
* @return The direction index (0-3) or -1 if not a movement key
|
||||||
|
*/
|
||||||
|
public static int getStruggleDirectionFromKeyCode(int keyCode) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.options == null) return -1;
|
||||||
|
if (mc.options.keyUp.matches(keyCode, 0)) return 0;
|
||||||
|
if (mc.options.keyLeft.matches(keyCode, 0)) return 1;
|
||||||
|
if (mc.options.keyDown.matches(keyCode, 0)) return 2;
|
||||||
|
if (mc.options.keyRight.matches(keyCode, 0)) return 3;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name of a vanilla movement key.
|
||||||
|
* Shows the actual bound key (W for QWERTY, Z for AZERTY, etc.)
|
||||||
|
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
|
||||||
|
* @return The key's display name
|
||||||
|
*/
|
||||||
|
public static String getStruggleDirectionKeyName(int index) {
|
||||||
|
KeyMapping key = getStruggleDirectionKey(index);
|
||||||
|
if (key == null) return "?";
|
||||||
|
return key.getTranslatedKeyMessage().getString().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle key presses on client tick.
|
||||||
|
* Called every client tick (FORGE bus).
|
||||||
|
*
|
||||||
|
* @param event The tick event
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
// Only run at end of tick
|
||||||
|
if (event.phase != TickEvent.Phase.END) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.player == null || mc.level == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync Force Seat keybind state to server (only send on change)
|
||||||
|
boolean currentForceSeatState = isForceSeatPressed();
|
||||||
|
if (currentForceSeatState != lastForceSeatState) {
|
||||||
|
lastForceSeatState = currentForceSeatState;
|
||||||
|
ModNetwork.sendToServer(
|
||||||
|
new PacketForceSeatModifier(currentForceSeatState)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check struggle key - Flow based on bind/accessories
|
||||||
|
while (STRUGGLE_KEY.consumeClick()) {
|
||||||
|
handleStruggleKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adjustment screen key
|
||||||
|
while (ADJUSTMENT_KEY.consumeClick()) {
|
||||||
|
// Only open if not already in a screen and player has adjustable items
|
||||||
|
if (mc.screen == null && AdjustmentScreen.canOpen()) {
|
||||||
|
mc.setScreen(new AdjustmentScreen());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Adjustment key pressed - opening screen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bondage inventory key - opens UnifiedBondageScreen in SELF or MASTER mode
|
||||||
|
while (INVENTORY_KEY.consumeClick()) {
|
||||||
|
if (mc.screen == null) {
|
||||||
|
LivingEntity masterTarget = findOwnedCollarTarget(mc.player);
|
||||||
|
if (masterTarget != null) {
|
||||||
|
mc.setScreen(new UnifiedBondageScreen(masterTarget));
|
||||||
|
} else {
|
||||||
|
mc.setScreen(new UnifiedBondageScreen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLAVE_MANAGEMENT_KEY: now handled by [J] with master mode detection (see above)
|
||||||
|
while (SLAVE_MANAGEMENT_KEY.consumeClick()) {
|
||||||
|
// consumed but no-op — kept registered to avoid key conflict during transition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bounty list key
|
||||||
|
while (BOUNTY_KEY.consumeClick()) {
|
||||||
|
// Request bounty list from server (server will open the screen)
|
||||||
|
if (mc.screen == null) {
|
||||||
|
ModNetwork.sendToServer(new PacketRequestBounties());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Bounty key pressed - requesting bounty list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tighten key
|
||||||
|
while (TIGHTEN_KEY.consumeClick()) {
|
||||||
|
// Send tighten packet to server (server finds target)
|
||||||
|
if (mc.screen == null) {
|
||||||
|
ModNetwork.sendToServer(new PacketTighten());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Tighten key pressed - sending tighten request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. If bind equipped: Send PacketStruggle to server (struggle against bind)
|
||||||
|
* 2. If no bind: Check for locked accessories
|
||||||
|
* - If locked accessories exist: Open StruggleChoiceScreen
|
||||||
|
* - If no locked accessories: Show "Nothing to struggle" message
|
||||||
|
*/
|
||||||
|
private static void handleStruggleKey() {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
Player player = mc.player;
|
||||||
|
if (player == null || mc.screen != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 path: check if player has V2 equipment to struggle against
|
||||||
|
if (
|
||||||
|
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(
|
||||||
|
player
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
handleV2Struggle(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if player has bind equipped
|
||||||
|
if (state.isTiedUp()) {
|
||||||
|
// Has bind - struggle against it
|
||||||
|
if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
|
||||||
|
// New: Start struggle mini-game
|
||||||
|
ModNetwork.sendToServer(
|
||||||
|
new PacketV2StruggleStart(BodyRegionV2.ARMS)
|
||||||
|
);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Struggle key pressed - starting V2 struggle mini-game"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Legacy: Probability-based struggle
|
||||||
|
ModNetwork.sendToServer(new PacketStruggle());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Struggle key pressed - legacy struggle against bind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bind - check for locked accessories
|
||||||
|
boolean hasLockedAccessories = hasAnyLockedAccessory(player);
|
||||||
|
|
||||||
|
if (hasLockedAccessories) {
|
||||||
|
// Open UnifiedBondageScreen in self mode
|
||||||
|
mc.setScreen(new UnifiedBondageScreen());
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Struggle key pressed - opening unified bondage screen"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No locked accessories - show message
|
||||||
|
player.displayClientMessage(
|
||||||
|
Component.translatable("tiedup.struggle.nothing").withStyle(
|
||||||
|
ChatFormatting.GRAY
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] Struggle key pressed - nothing to struggle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle struggle key for V2 equipment.
|
||||||
|
* Auto-targets the highest posePriority item.
|
||||||
|
*/
|
||||||
|
private static void handleV2Struggle(Player player) {
|
||||||
|
java.util.Map<com.tiedup.remake.v2.BodyRegionV2, ItemStack> equipped =
|
||||||
|
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(
|
||||||
|
player
|
||||||
|
);
|
||||||
|
|
||||||
|
if (equipped.isEmpty()) return;
|
||||||
|
|
||||||
|
// Auto-target: find highest posePriority item
|
||||||
|
com.tiedup.remake.v2.BodyRegionV2 bestRegion = null;
|
||||||
|
int bestPriority = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
for (java.util.Map.Entry<
|
||||||
|
com.tiedup.remake.v2.BodyRegionV2,
|
||||||
|
ItemStack
|
||||||
|
> entry : equipped.entrySet()) {
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
if (
|
||||||
|
stack.getItem() instanceof
|
||||||
|
com.tiedup.remake.v2.bondage.IV2BondageItem item
|
||||||
|
) {
|
||||||
|
if (item.getPosePriority(stack) > bestPriority) {
|
||||||
|
bestPriority = item.getPosePriority(stack);
|
||||||
|
bestRegion = entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestRegion != null) {
|
||||||
|
ModNetwork.sendToServer(
|
||||||
|
new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(
|
||||||
|
bestRegion
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[CLIENT] V2 Struggle key pressed - targeting region {}",
|
||||||
|
bestRegion.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the crosshair entity: if it is a LivingEntity wearing a collar owned by the player,
|
||||||
|
* return it as the MASTER mode target. Returns null if no valid target.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private static LivingEntity findOwnedCollarTarget(Player player) {
|
||||||
|
if (player == null) return null;
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
net.minecraft.world.entity.Entity crosshair = mc.crosshairPickEntity;
|
||||||
|
if (crosshair instanceof LivingEntity living) {
|
||||||
|
return checkCollarOwnership(living, player) ? living : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given entity has a collar in the NECK region that lists the player as an owner.
|
||||||
|
*/
|
||||||
|
private static boolean checkCollarOwnership(
|
||||||
|
LivingEntity target,
|
||||||
|
Player player
|
||||||
|
) {
|
||||||
|
ItemStack collarStack =
|
||||||
|
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
|
||||||
|
target,
|
||||||
|
BodyRegionV2.NECK
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!collarStack.isEmpty() &&
|
||||||
|
collarStack.getItem() instanceof ItemCollar collar
|
||||||
|
) {
|
||||||
|
return collar.isOwner(collarStack, player);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player has any locked accessories.
|
||||||
|
*/
|
||||||
|
private static boolean hasAnyLockedAccessory(Player player) {
|
||||||
|
BodyRegionV2[] accessoryRegions = {
|
||||||
|
BodyRegionV2.MOUTH,
|
||||||
|
BodyRegionV2.EYES,
|
||||||
|
BodyRegionV2.EARS,
|
||||||
|
BodyRegionV2.NECK,
|
||||||
|
BodyRegionV2.TORSO,
|
||||||
|
BodyRegionV2.HANDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (BodyRegionV2 region : accessoryRegions) {
|
||||||
|
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
|
||||||
|
if (
|
||||||
|
!stack.isEmpty() &&
|
||||||
|
stack.getItem() instanceof ILockable lockable
|
||||||
|
) {
|
||||||
|
if (lockable.isLocked(stack)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
Normal file
118
src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package com.tiedup.remake.client;
|
||||||
|
|
||||||
|
import net.minecraft.client.resources.sounds.Sound;
|
||||||
|
import net.minecraft.client.resources.sounds.SoundInstance;
|
||||||
|
import net.minecraft.client.resources.sounds.TickableSoundInstance;
|
||||||
|
import net.minecraft.client.sounds.SoundManager;
|
||||||
|
import net.minecraft.client.sounds.WeighedSoundEvents;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.sounds.SoundSource;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around a SoundInstance that applies volume and pitch modifiers.
|
||||||
|
* Used for the earplugs muffling effect.
|
||||||
|
*
|
||||||
|
* This delegates all methods to the wrapped sound, but overrides
|
||||||
|
* getVolume() and getPitch() to apply modifiers.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class MuffledSoundInstance implements SoundInstance {
|
||||||
|
|
||||||
|
private final SoundInstance wrapped;
|
||||||
|
private final float volumeMultiplier;
|
||||||
|
private final float pitchMultiplier;
|
||||||
|
|
||||||
|
public MuffledSoundInstance(
|
||||||
|
SoundInstance wrapped,
|
||||||
|
float volumeMultiplier,
|
||||||
|
float pitchMultiplier
|
||||||
|
) {
|
||||||
|
this.wrapped = wrapped;
|
||||||
|
this.volumeMultiplier = volumeMultiplier;
|
||||||
|
this.pitchMultiplier = pitchMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResourceLocation getLocation() {
|
||||||
|
return wrapped.getLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WeighedSoundEvents resolve(SoundManager soundManager) {
|
||||||
|
return wrapped.resolve(soundManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Sound getSound() {
|
||||||
|
return wrapped.getSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SoundSource getSource() {
|
||||||
|
return wrapped.getSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isLooping() {
|
||||||
|
return wrapped.isLooping();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRelative() {
|
||||||
|
return wrapped.isRelative();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getDelay() {
|
||||||
|
return wrapped.getDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getVolume() {
|
||||||
|
// Apply muffling to volume
|
||||||
|
return wrapped.getVolume() * volumeMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getPitch() {
|
||||||
|
// Apply muffling to pitch
|
||||||
|
return wrapped.getPitch() * pitchMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getX() {
|
||||||
|
return wrapped.getX();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getY() {
|
||||||
|
return wrapped.getY();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getZ() {
|
||||||
|
return wrapped.getZ();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Attenuation getAttenuation() {
|
||||||
|
return wrapped.getAttenuation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is wrapping a tickable sound.
|
||||||
|
* Used to handle special cases.
|
||||||
|
*/
|
||||||
|
public boolean isTickable() {
|
||||||
|
return wrapped instanceof TickableSoundInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the wrapped sound instance.
|
||||||
|
*/
|
||||||
|
public SoundInstance getWrapped() {
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.tiedup.remake.client.animation;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central registry for player animation state tracking.
|
||||||
|
*
|
||||||
|
* <p>Holds per-player state maps that were previously scattered across
|
||||||
|
* AnimationTickHandler. Provides a single clearAll() entry point for
|
||||||
|
* world unload cleanup.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class AnimationStateRegistry {
|
||||||
|
|
||||||
|
/** Track last tied state per player */
|
||||||
|
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Track last animation ID per player to avoid redundant updates */
|
||||||
|
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private AnimationStateRegistry() {}
|
||||||
|
|
||||||
|
public static Map<UUID, Boolean> getLastTiedState() {
|
||||||
|
return lastTiedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<UUID, String> getLastAnimId() {
|
||||||
|
return lastAnimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all animation-related state in one call.
|
||||||
|
* Called on world unload to prevent memory leaks and stale data.
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
// Animation state tracking
|
||||||
|
lastTiedState.clear();
|
||||||
|
lastAnimId.clear();
|
||||||
|
|
||||||
|
// Animation managers
|
||||||
|
BondageAnimationManager.clearAll();
|
||||||
|
PendingAnimationManager.clearAll();
|
||||||
|
|
||||||
|
// V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache)
|
||||||
|
com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll();
|
||||||
|
|
||||||
|
// Render state
|
||||||
|
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
|
||||||
|
|
||||||
|
// NPC animation state
|
||||||
|
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
|
||||||
|
|
||||||
|
// MCA animation cache
|
||||||
|
com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
package com.tiedup.remake.client.animation;
|
||||||
|
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import com.tiedup.remake.v2.furniture.ISeatProvider;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.IAnimation;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
||||||
|
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
|
||||||
|
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory;
|
||||||
|
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified animation manager for bondage animations.
|
||||||
|
*
|
||||||
|
* <p>Handles both players and NPCs (any entity implementing IAnimatedPlayer).
|
||||||
|
* Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support.
|
||||||
|
*
|
||||||
|
* <p>This replaces the previous split system:
|
||||||
|
* <ul>
|
||||||
|
* <li>PlayerAnimatorBridge (for players)</li>
|
||||||
|
* <li>DamselAnimationManager (for NPCs)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class BondageAnimationManager {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
/** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */
|
||||||
|
private static final Map<UUID, ModifierLayer<IAnimation>> npcLayers =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Cache of context ModifierLayers for NPC entities */
|
||||||
|
private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Cache of furniture ModifierLayers for NPC entities */
|
||||||
|
private static final Map<
|
||||||
|
UUID,
|
||||||
|
ModifierLayer<IAnimation>
|
||||||
|
> npcFurnitureLayers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Factory ID for PlayerAnimator item layer (players only) */
|
||||||
|
private static final ResourceLocation FACTORY_ID =
|
||||||
|
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage");
|
||||||
|
|
||||||
|
/** Factory ID for PlayerAnimator context layer (players only) */
|
||||||
|
private static final ResourceLocation CONTEXT_FACTORY_ID =
|
||||||
|
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context");
|
||||||
|
|
||||||
|
/** Factory ID for PlayerAnimator furniture layer (players only) */
|
||||||
|
private static final ResourceLocation FURNITURE_FACTORY_ID =
|
||||||
|
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture");
|
||||||
|
|
||||||
|
/** Priority for context animation layer (lower = overridable by item layer) */
|
||||||
|
private static final int CONTEXT_LAYER_PRIORITY = 40;
|
||||||
|
/** Priority for item animation layer (higher = overrides context layer) */
|
||||||
|
private static final int ITEM_LAYER_PRIORITY = 42;
|
||||||
|
/**
|
||||||
|
* Priority for furniture animation layer (highest = overrides item layer on blocked bones).
|
||||||
|
* Non-blocked bones are disabled so items can still animate them via the item layer.
|
||||||
|
*/
|
||||||
|
private static final int FURNITURE_LAYER_PRIORITY = 43;
|
||||||
|
|
||||||
|
/** Number of ticks to wait before removing a stale furniture animation. */
|
||||||
|
private static final int FURNITURE_GRACE_TICKS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks ticks since a player with an active furniture animation stopped riding
|
||||||
|
* an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed
|
||||||
|
* to prevent stuck poses from entity death or network issues.
|
||||||
|
*
|
||||||
|
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
|
||||||
|
*/
|
||||||
|
private static final Map<UUID, Integer> furnitureGraceTicks =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the animation system.
|
||||||
|
* Must be called during client setup to register the player animation factory.
|
||||||
|
*/
|
||||||
|
public static void init() {
|
||||||
|
LOGGER.info("BondageAnimationManager initializing...");
|
||||||
|
|
||||||
|
// Context layer: lower priority = evaluated first, overridable by item layer.
|
||||||
|
// In AnimationStack, layers are sorted ascending by priority and evaluated in order.
|
||||||
|
// Higher priority layers override lower ones.
|
||||||
|
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||||
|
CONTEXT_FACTORY_ID,
|
||||||
|
CONTEXT_LAYER_PRIORITY,
|
||||||
|
player -> new ModifierLayer<>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Item layer: higher priority = evaluated last, overrides context layer
|
||||||
|
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||||
|
FACTORY_ID,
|
||||||
|
ITEM_LAYER_PRIORITY,
|
||||||
|
player -> new ModifierLayer<>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Furniture layer: highest priority = overrides item layer on blocked bones.
|
||||||
|
// Non-blocked bones are disabled via FurnitureAnimationContext so items
|
||||||
|
// can still animate free regions (gag, blindfold, etc.).
|
||||||
|
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||||
|
FURNITURE_FACTORY_ID,
|
||||||
|
FURNITURE_LAYER_PRIORITY,
|
||||||
|
player -> new ModifierLayer<>()
|
||||||
|
);
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})",
|
||||||
|
CONTEXT_LAYER_PRIORITY,
|
||||||
|
ITEM_LAYER_PRIORITY,
|
||||||
|
FURNITURE_LAYER_PRIORITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAY ANIMATION
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play an animation on any entity (player or NPC).
|
||||||
|
*
|
||||||
|
* @param entity The entity to animate
|
||||||
|
* @param animId Animation ID string (will be prefixed with "tiedup:" namespace)
|
||||||
|
* @return true if animation started successfully, false if layer not available
|
||||||
|
*/
|
||||||
|
public static boolean playAnimation(LivingEntity entity, String animId) {
|
||||||
|
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"tiedup",
|
||||||
|
animId
|
||||||
|
);
|
||||||
|
return playAnimation(entity, location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play an animation on any entity (player or NPC).
|
||||||
|
*
|
||||||
|
* <p>If the animation layer is not available (e.g., remote player not fully
|
||||||
|
* initialized), the animation will be queued for retry via PendingAnimationManager.
|
||||||
|
*
|
||||||
|
* @param entity The entity to animate
|
||||||
|
* @param animId Full ResourceLocation of the animation
|
||||||
|
* @return true if animation started successfully, false if layer not available
|
||||||
|
*/
|
||||||
|
public static boolean playAnimation(
|
||||||
|
LivingEntity entity,
|
||||||
|
ResourceLocation animId
|
||||||
|
) {
|
||||||
|
if (entity == null || !entity.level().isClientSide()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId);
|
||||||
|
if (anim == null) {
|
||||||
|
// Try fallback: remove _sneak_ suffix if present
|
||||||
|
ResourceLocation fallbackId = tryFallbackAnimation(animId);
|
||||||
|
if (fallbackId != null) {
|
||||||
|
anim = PlayerAnimationRegistry.getAnimation(fallbackId);
|
||||||
|
if (anim != null) {
|
||||||
|
LOGGER.debug(
|
||||||
|
"Using fallback animation '{}' for missing '{}'",
|
||||||
|
fallbackId,
|
||||||
|
animId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anim == null) {
|
||||||
|
LOGGER.warn("Animation not found in registry: {}", animId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
|
||||||
|
if (layer != null) {
|
||||||
|
// Check if same animation is already playing
|
||||||
|
// Use reference comparison (==) instead of equals() because:
|
||||||
|
// 1. PlayerAnimationRegistry caches animations by ID
|
||||||
|
// 2. Same ID = same cached object reference
|
||||||
|
// 3. This avoids issues with KeyframeAnimation.equals() implementation
|
||||||
|
IAnimation current = layer.getAnimation();
|
||||||
|
if (current instanceof KeyframeAnimationPlayer player) {
|
||||||
|
if (player.getData() == anim) {
|
||||||
|
// Same animation already playing, don't reset
|
||||||
|
return true; // Still counts as success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||||
|
|
||||||
|
// Remove from pending queue if it was waiting
|
||||||
|
PendingAnimationManager.remove(entity.getUUID());
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"Playing animation '{}' on entity: {}",
|
||||||
|
animId,
|
||||||
|
entity.getUUID()
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Layer not available - queue for retry if it's a player
|
||||||
|
if (entity instanceof AbstractClientPlayer) {
|
||||||
|
PendingAnimationManager.queueForRetry(
|
||||||
|
entity.getUUID(),
|
||||||
|
animId.getPath()
|
||||||
|
);
|
||||||
|
LOGGER.debug(
|
||||||
|
"Animation layer not ready for {}, queued for retry",
|
||||||
|
entity.getName().getString()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
LOGGER.warn(
|
||||||
|
"Animation layer is NULL for NPC: {} (type: {})",
|
||||||
|
entity.getName().getString(),
|
||||||
|
entity.getClass().getSimpleName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a pre-converted KeyframeAnimation directly on an entity, bypassing the registry.
|
||||||
|
* Used by GltfAnimationApplier for GLB-converted poses.
|
||||||
|
*
|
||||||
|
* @param entity The entity to animate
|
||||||
|
* @param anim The KeyframeAnimation to play
|
||||||
|
* @return true if animation started successfully
|
||||||
|
*/
|
||||||
|
public static boolean playDirect(
|
||||||
|
LivingEntity entity,
|
||||||
|
KeyframeAnimation anim
|
||||||
|
) {
|
||||||
|
if (entity == null || anim == null || !entity.level().isClientSide()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
|
||||||
|
if (layer != null) {
|
||||||
|
IAnimation current = layer.getAnimation();
|
||||||
|
if (current instanceof KeyframeAnimationPlayer player) {
|
||||||
|
if (player.getData() == anim) {
|
||||||
|
return true; // Same animation already playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||||
|
PendingAnimationManager.remove(entity.getUUID());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STOP ANIMATION
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop any currently playing animation on an entity.
|
||||||
|
*
|
||||||
|
* @param entity The entity
|
||||||
|
*/
|
||||||
|
public static void stopAnimation(LivingEntity entity) {
|
||||||
|
if (entity == null || !entity.level().isClientSide()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getLayer(entity);
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(null);
|
||||||
|
LOGGER.debug("Stopped animation on entity: {}", entity.getUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAYER MANAGEMENT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ModifierLayer for an entity (without creating).
|
||||||
|
*/
|
||||||
|
private static ModifierLayer<IAnimation> getLayer(LivingEntity entity) {
|
||||||
|
// Players: try PlayerAnimationAccess first, then cache
|
||||||
|
if (entity instanceof AbstractClientPlayer player) {
|
||||||
|
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||||
|
if (factoryLayer != null) {
|
||||||
|
return factoryLayer;
|
||||||
|
}
|
||||||
|
// Check cache (for remote players using fallback)
|
||||||
|
return npcLayers.get(entity.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
// NPCs: use cache
|
||||||
|
return npcLayers.get(entity.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the ModifierLayer for an entity.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static ModifierLayer<IAnimation> getOrCreateLayer(
|
||||||
|
LivingEntity entity
|
||||||
|
) {
|
||||||
|
UUID uuid = entity.getUUID();
|
||||||
|
|
||||||
|
// Players: try factory-based access first, fallback to direct stack access
|
||||||
|
if (entity instanceof AbstractClientPlayer player) {
|
||||||
|
// Try the registered factory first (works for local player)
|
||||||
|
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||||
|
if (factoryLayer != null) {
|
||||||
|
return factoryLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for remote players: use direct stack access like NPCs
|
||||||
|
// This handles cases where the factory data isn't available
|
||||||
|
if (player instanceof IAnimatedPlayer animated) {
|
||||||
|
return npcLayers.computeIfAbsent(uuid, k -> {
|
||||||
|
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
|
||||||
|
animated
|
||||||
|
.getAnimationStack()
|
||||||
|
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
|
||||||
|
LOGGER.info(
|
||||||
|
"Created animation layer for remote player via stack: {}",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return newLayer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NPCs implementing IAnimatedPlayer: create/cache layer
|
||||||
|
if (entity instanceof IAnimatedPlayer animated) {
|
||||||
|
return npcLayers.computeIfAbsent(uuid, k -> {
|
||||||
|
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
|
||||||
|
animated
|
||||||
|
.getAnimationStack()
|
||||||
|
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
|
||||||
|
LOGGER.debug("Created animation layer for NPC: {}", uuid);
|
||||||
|
return newLayer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.warn(
|
||||||
|
"Entity {} does not support animations (not a player or IAnimatedPlayer)",
|
||||||
|
uuid
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the animation layer for a player from PlayerAnimationAccess.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static ModifierLayer<IAnimation> getPlayerLayer(
|
||||||
|
AbstractClientPlayer player
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return (ModifierLayer<
|
||||||
|
IAnimation
|
||||||
|
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
|
||||||
|
FACTORY_ID
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error(
|
||||||
|
"Failed to get animation layer for player: {}",
|
||||||
|
player.getName().getString(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely get the animation layer for a player.
|
||||||
|
* Returns null if the layer is not yet initialized.
|
||||||
|
*
|
||||||
|
* <p>Public method for PendingAnimationManager to access.
|
||||||
|
* Checks both the factory-based layer and the NPC cache fallback.
|
||||||
|
*
|
||||||
|
* @param player The player
|
||||||
|
* @return The animation layer, or null if not available
|
||||||
|
*/
|
||||||
|
@javax.annotation.Nullable
|
||||||
|
public static ModifierLayer<IAnimation> getPlayerLayerSafe(
|
||||||
|
AbstractClientPlayer player
|
||||||
|
) {
|
||||||
|
// Try factory first
|
||||||
|
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||||
|
if (factoryLayer != null) {
|
||||||
|
return factoryLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NPC cache (for remote players using fallback path)
|
||||||
|
return npcLayers.get(player.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTEXT LAYER (lower priority, for sit/kneel/sneak)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context animation layer for a player from PlayerAnimationAccess.
|
||||||
|
* Returns null if the layer is not yet initialized.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@javax.annotation.Nullable
|
||||||
|
private static ModifierLayer<IAnimation> getPlayerContextLayer(
|
||||||
|
AbstractClientPlayer player
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return (ModifierLayer<
|
||||||
|
IAnimation
|
||||||
|
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
|
||||||
|
CONTEXT_FACTORY_ID
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the context animation layer for an NPC entity.
|
||||||
|
* Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY.
|
||||||
|
*/
|
||||||
|
@javax.annotation.Nullable
|
||||||
|
private static ModifierLayer<IAnimation> getOrCreateNpcContextLayer(
|
||||||
|
LivingEntity entity
|
||||||
|
) {
|
||||||
|
if (entity instanceof IAnimatedPlayer animated) {
|
||||||
|
return npcContextLayers.computeIfAbsent(entity.getUUID(), k -> {
|
||||||
|
ModifierLayer<IAnimation> layer = new ModifierLayer<>();
|
||||||
|
animated
|
||||||
|
.getAnimationStack()
|
||||||
|
.addAnimLayer(CONTEXT_LAYER_PRIORITY, layer);
|
||||||
|
return layer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a context animation on the context layer (lower priority).
|
||||||
|
* Context animations (sit, kneel, sneak) can be overridden by item animations
|
||||||
|
* on the main layer which has higher priority.
|
||||||
|
*
|
||||||
|
* @param entity The entity to animate
|
||||||
|
* @param anim The KeyframeAnimation to play on the context layer
|
||||||
|
* @return true if animation started successfully
|
||||||
|
*/
|
||||||
|
public static boolean playContext(
|
||||||
|
LivingEntity entity,
|
||||||
|
KeyframeAnimation anim
|
||||||
|
) {
|
||||||
|
if (entity == null || anim == null || !entity.level().isClientSide()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer;
|
||||||
|
if (entity instanceof AbstractClientPlayer player) {
|
||||||
|
layer = getPlayerContextLayer(player);
|
||||||
|
} else {
|
||||||
|
layer = getOrCreateNpcContextLayer(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the context layer animation.
|
||||||
|
*
|
||||||
|
* @param entity The entity whose context animation should stop
|
||||||
|
*/
|
||||||
|
public static void stopContext(LivingEntity entity) {
|
||||||
|
if (entity == null || !entity.level().isClientSide()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer;
|
||||||
|
if (entity instanceof AbstractClientPlayer player) {
|
||||||
|
layer = getPlayerContextLayer(player);
|
||||||
|
} else {
|
||||||
|
layer = npcContextLayers.get(entity.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FURNITURE LAYER (highest priority, for seat poses)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a furniture animation on the furniture layer (highest priority).
|
||||||
|
*
|
||||||
|
* <p>The furniture layer sits above the item layer so it controls blocked-region
|
||||||
|
* bones. Non-blocked bones should already be disabled in the provided animation
|
||||||
|
* (via {@link com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext#create}).
|
||||||
|
* This allows bondage items on free regions to still animate via the item layer.</p>
|
||||||
|
*
|
||||||
|
* @param player the player to animate
|
||||||
|
* @param animation the KeyframeAnimation from FurnitureAnimationContext
|
||||||
|
* @return true if animation started successfully
|
||||||
|
*/
|
||||||
|
public static boolean playFurniture(
|
||||||
|
Player player,
|
||||||
|
KeyframeAnimation animation
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
player == null ||
|
||||||
|
animation == null ||
|
||||||
|
!player.level().isClientSide()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(new KeyframeAnimationPlayer(animation));
|
||||||
|
// Reset grace ticks since we just started/refreshed the animation
|
||||||
|
furnitureGraceTicks.remove(player.getUUID());
|
||||||
|
LOGGER.debug(
|
||||||
|
"Playing furniture animation on player: {}",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.warn(
|
||||||
|
"Furniture layer not available for player: {}",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the furniture layer animation for a player.
|
||||||
|
*
|
||||||
|
* @param player the player whose furniture animation should stop
|
||||||
|
*/
|
||||||
|
public static void stopFurniture(Player player) {
|
||||||
|
if (player == null || !player.level().isClientSide()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(null);
|
||||||
|
}
|
||||||
|
furnitureGraceTicks.remove(player.getUUID());
|
||||||
|
LOGGER.debug(
|
||||||
|
"Stopped furniture animation on player: {}",
|
||||||
|
player.getName().getString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a player currently has an active furniture animation.
|
||||||
|
*
|
||||||
|
* @param player the player to check
|
||||||
|
* @return true if the furniture layer has an active animation
|
||||||
|
*/
|
||||||
|
public static boolean hasFurnitureAnimation(Player player) {
|
||||||
|
if (player == null || !player.level().isClientSide()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||||
|
return layer != null && layer.getAnimation() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the furniture ModifierLayer for a player.
|
||||||
|
* Uses PlayerAnimationAccess for local/factory-registered players,
|
||||||
|
* falls back to NPC cache for remote players.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@javax.annotation.Nullable
|
||||||
|
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
|
||||||
|
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||||
|
try {
|
||||||
|
ModifierLayer<IAnimation> layer = (ModifierLayer<
|
||||||
|
IAnimation
|
||||||
|
>) PlayerAnimationAccess.getPlayerAssociatedData(
|
||||||
|
clientPlayer
|
||||||
|
).get(FURNITURE_FACTORY_ID);
|
||||||
|
if (layer != null) {
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fall through to NPC cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for remote players: check NPC furniture cache
|
||||||
|
return npcFurnitureLayers.get(player.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-player entities: use NPC cache
|
||||||
|
return npcFurnitureLayers.get(player.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety tick for furniture animations. Call once per client tick per player.
|
||||||
|
*
|
||||||
|
* <p>If a player has an active furniture animation but is NOT riding an
|
||||||
|
* {@link ISeatProvider}, increment a grace counter. After
|
||||||
|
* {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the
|
||||||
|
* animation is removed to prevent stuck poses from entity death, network
|
||||||
|
* desync, or teleportation.</p>
|
||||||
|
*
|
||||||
|
* <p>If the player IS riding an ISeatProvider, the counter is reset.</p>
|
||||||
|
*
|
||||||
|
* @param player the player to check
|
||||||
|
*/
|
||||||
|
public static void tickFurnitureSafety(Player player) {
|
||||||
|
if (player == null || !player.level().isClientSide()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFurnitureAnimation(player)) {
|
||||||
|
// No furniture animation active, nothing to guard
|
||||||
|
furnitureGraceTicks.remove(player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID uuid = player.getUUID();
|
||||||
|
|
||||||
|
// Check if the player is riding an ISeatProvider
|
||||||
|
Entity vehicle = player.getVehicle();
|
||||||
|
boolean ridingSeat = vehicle instanceof ISeatProvider;
|
||||||
|
|
||||||
|
if (ridingSeat) {
|
||||||
|
// Player is properly seated, reset grace counter
|
||||||
|
furnitureGraceTicks.remove(uuid);
|
||||||
|
} else {
|
||||||
|
// Player has furniture anim but no seat -- increment grace
|
||||||
|
int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
|
||||||
|
if (ticks >= FURNITURE_GRACE_TICKS) {
|
||||||
|
LOGGER.info(
|
||||||
|
"Removing stale furniture animation for player {} " +
|
||||||
|
"(not riding ISeatProvider for {} ticks)",
|
||||||
|
player.getName().getString(),
|
||||||
|
ticks
|
||||||
|
);
|
||||||
|
stopFurniture(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FALLBACK ANIMATION HANDLING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find a fallback animation ID when the requested one doesn't exist.
|
||||||
|
*
|
||||||
|
* <p>Fallback chain:
|
||||||
|
* <ol>
|
||||||
|
* <li>Remove _sneak_ suffix (sneak variants often missing)</li>
|
||||||
|
* <li>For sit_dog/kneel_dog variants, fall back to basic standing DOG</li>
|
||||||
|
* <li>For _arms_ variants, try FULL variant</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param originalId The original animation ID that wasn't found
|
||||||
|
* @return A fallback ResourceLocation to try, or null if no fallback
|
||||||
|
*/
|
||||||
|
@javax.annotation.Nullable
|
||||||
|
private static ResourceLocation tryFallbackAnimation(
|
||||||
|
ResourceLocation originalId
|
||||||
|
) {
|
||||||
|
String path = originalId.getPath();
|
||||||
|
String namespace = originalId.getNamespace();
|
||||||
|
|
||||||
|
// 1. Remove _sneak_ suffix
|
||||||
|
if (path.contains("_sneak_")) {
|
||||||
|
String fallback = path.replace("_sneak_", "_");
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. sit_dog_* / kneel_dog_* -> tied_up_dog_*
|
||||||
|
if (path.startsWith("sit_dog_") || path.startsWith("kneel_dog_")) {
|
||||||
|
String suffix = path.substring(path.lastIndexOf("_")); // _idle or _struggle
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(
|
||||||
|
namespace,
|
||||||
|
"tied_up_dog" + suffix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. _arms_ variants -> try FULL variant (remove _arms)
|
||||||
|
if (path.contains("_arms_")) {
|
||||||
|
String fallback = path.replace("_arms_", "_");
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Struggle variants for free/legs -> idle variant
|
||||||
|
if (
|
||||||
|
(path.startsWith("sit_free_") ||
|
||||||
|
path.startsWith("kneel_free_") ||
|
||||||
|
path.startsWith("sit_legs_") ||
|
||||||
|
path.startsWith("kneel_legs_")) &&
|
||||||
|
path.endsWith("_struggle")
|
||||||
|
) {
|
||||||
|
String fallback = path.replace("_struggle", "_idle");
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up animation layer for an NPC when it's removed.
|
||||||
|
*
|
||||||
|
* @param entityId UUID of the removed entity
|
||||||
|
*/
|
||||||
|
/** All NPC layer caches, for bulk cleanup operations. */
|
||||||
|
private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES =
|
||||||
|
new Map[] { npcLayers, npcContextLayers, npcFurnitureLayers };
|
||||||
|
|
||||||
|
public static void cleanup(UUID entityId) {
|
||||||
|
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
||||||
|
ModifierLayer<IAnimation> layer = cache.remove(entityId);
|
||||||
|
if (layer != null) {
|
||||||
|
layer.setAnimation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
furnitureGraceTicks.remove(entityId);
|
||||||
|
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all NPC animation layers.
|
||||||
|
* Should be called on world unload.
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
||||||
|
cache.values().forEach(layer -> layer.setAnimation(null));
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
furnitureGraceTicks.clear();
|
||||||
|
LOGGER.info("Cleared all NPC animation layers");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.tiedup.remake.client.animation;
|
||||||
|
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.IAnimation;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
|
||||||
|
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages pending animations for remote players whose animation layers
|
||||||
|
* may not be immediately available due to timing issues.
|
||||||
|
*
|
||||||
|
* <p>When a player is tied, the sync packet may arrive before the remote player's
|
||||||
|
* animation layer is initialized by PlayerAnimator. This class queues failed
|
||||||
|
* animation attempts and retries them each tick until success or timeout.
|
||||||
|
*
|
||||||
|
* <p>This follows the same pattern as SyncManager's pending queue for inventory sync.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class PendingAnimationManager {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
/** Pending animations waiting for layer initialization */
|
||||||
|
private static final Map<UUID, PendingEntry> pending =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */
|
||||||
|
private static final int MAX_RETRIES = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a player's animation for retry.
|
||||||
|
* Called when playAnimation fails due to null layer.
|
||||||
|
*
|
||||||
|
* @param uuid The player's UUID
|
||||||
|
* @param animId The animation ID (without namespace)
|
||||||
|
*/
|
||||||
|
public static void queueForRetry(UUID uuid, String animId) {
|
||||||
|
pending.compute(uuid, (k, existing) -> {
|
||||||
|
if (existing == null) {
|
||||||
|
LOGGER.debug(
|
||||||
|
"Queued animation '{}' for retry on player {}",
|
||||||
|
animId,
|
||||||
|
uuid
|
||||||
|
);
|
||||||
|
return new PendingEntry(animId, 0);
|
||||||
|
}
|
||||||
|
// Update animation ID but preserve retry count
|
||||||
|
return new PendingEntry(animId, existing.retries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a player from the pending queue.
|
||||||
|
* Called when animation succeeds or player disconnects.
|
||||||
|
*
|
||||||
|
* @param uuid The player's UUID
|
||||||
|
*/
|
||||||
|
public static void remove(UUID uuid) {
|
||||||
|
pending.remove(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a player has a pending animation.
|
||||||
|
*
|
||||||
|
* @param uuid The player's UUID
|
||||||
|
* @return true if pending
|
||||||
|
*/
|
||||||
|
public static boolean hasPending(UUID uuid) {
|
||||||
|
return pending.containsKey(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending animations. Called every tick from AnimationTickHandler.
|
||||||
|
* Attempts to play queued animations and removes successful or expired entries.
|
||||||
|
*
|
||||||
|
* @param level The client level
|
||||||
|
*/
|
||||||
|
public static void processPending(ClientLevel level) {
|
||||||
|
if (pending.isEmpty()) return;
|
||||||
|
|
||||||
|
Iterator<Map.Entry<UUID, PendingEntry>> it = pending
|
||||||
|
.entrySet()
|
||||||
|
.iterator();
|
||||||
|
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<UUID, PendingEntry> entry = it.next();
|
||||||
|
UUID uuid = entry.getKey();
|
||||||
|
PendingEntry pe = entry.getValue();
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (pe.retries >= MAX_RETRIES) {
|
||||||
|
LOGGER.warn("Animation retry exhausted for player {}", uuid);
|
||||||
|
it.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find player and play animation
|
||||||
|
Player player = level.getPlayerByUUID(uuid);
|
||||||
|
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||||
|
ModifierLayer<IAnimation> layer =
|
||||||
|
BondageAnimationManager.getPlayerLayerSafe(clientPlayer);
|
||||||
|
|
||||||
|
if (layer != null) {
|
||||||
|
ResourceLocation loc =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"tiedup",
|
||||||
|
pe.animId
|
||||||
|
);
|
||||||
|
KeyframeAnimation anim =
|
||||||
|
PlayerAnimationRegistry.getAnimation(loc);
|
||||||
|
|
||||||
|
if (anim != null) {
|
||||||
|
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||||
|
LOGGER.info(
|
||||||
|
"Animation retry succeeded for {} after {} attempts",
|
||||||
|
clientPlayer.getName().getString(),
|
||||||
|
pe.retries
|
||||||
|
);
|
||||||
|
it.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment retry count
|
||||||
|
pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all pending animations.
|
||||||
|
* Called on world unload.
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
pending.clear();
|
||||||
|
LOGGER.debug("Cleared all pending animations");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record to store pending animation data.
|
||||||
|
*/
|
||||||
|
private record PendingEntry(String animId, int retries) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.tiedup.remake.client.animation;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies static bondage poses directly to HumanoidModel.
|
||||||
|
*
|
||||||
|
* <p>Used for entities that don't support PlayerAnimator (e.g., MCA villagers).
|
||||||
|
* Directly modifies arm/leg rotations on the model.
|
||||||
|
*
|
||||||
|
* <p>Extracted from BondageAnimationManager to separate concerns:
|
||||||
|
* BondageAnimationManager handles PlayerAnimator layers,
|
||||||
|
* StaticPoseApplier handles raw model manipulation.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class StaticPoseApplier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a static bondage pose directly to a HumanoidModel.
|
||||||
|
*
|
||||||
|
* @param model The humanoid model to modify
|
||||||
|
* @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK)
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
*/
|
||||||
|
public static void applyStaticPose(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
PoseType poseType,
|
||||||
|
boolean armsBound,
|
||||||
|
boolean legsBound
|
||||||
|
) {
|
||||||
|
if (model == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBodyPose(model, poseType);
|
||||||
|
|
||||||
|
if (armsBound) {
|
||||||
|
applyArmPose(model, poseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legsBound) {
|
||||||
|
applyLegPose(model, poseType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply arm pose based on pose type.
|
||||||
|
* Values converted from animation JSON (degrees to radians).
|
||||||
|
*/
|
||||||
|
private static void applyArmPose(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
PoseType poseType
|
||||||
|
) {
|
||||||
|
switch (poseType) {
|
||||||
|
case STANDARD -> {
|
||||||
|
model.rightArm.xRot = 0.899f;
|
||||||
|
model.rightArm.yRot = 1.0f;
|
||||||
|
model.rightArm.zRot = 0f;
|
||||||
|
model.leftArm.xRot = 0.899f;
|
||||||
|
model.leftArm.yRot = -1.0f;
|
||||||
|
model.leftArm.zRot = 0f;
|
||||||
|
}
|
||||||
|
case STRAITJACKET -> {
|
||||||
|
model.rightArm.xRot = 0.764f;
|
||||||
|
model.rightArm.yRot = -0.84f;
|
||||||
|
model.rightArm.zRot = 0f;
|
||||||
|
model.leftArm.xRot = 0.764f;
|
||||||
|
model.leftArm.yRot = 0.84f;
|
||||||
|
model.leftArm.zRot = 0f;
|
||||||
|
}
|
||||||
|
case WRAP, LATEX_SACK -> {
|
||||||
|
model.rightArm.xRot = 0f;
|
||||||
|
model.rightArm.yRot = 0f;
|
||||||
|
model.rightArm.zRot = -0.087f;
|
||||||
|
model.leftArm.xRot = 0f;
|
||||||
|
model.leftArm.yRot = 0f;
|
||||||
|
model.leftArm.zRot = 0.087f;
|
||||||
|
}
|
||||||
|
case DOG -> {
|
||||||
|
model.rightArm.xRot = -2.094f;
|
||||||
|
model.rightArm.yRot = 0.175f;
|
||||||
|
model.rightArm.zRot = 0f;
|
||||||
|
model.leftArm.xRot = -2.094f;
|
||||||
|
model.leftArm.yRot = -0.175f;
|
||||||
|
model.leftArm.zRot = 0f;
|
||||||
|
}
|
||||||
|
case HUMAN_CHAIR -> {
|
||||||
|
model.rightArm.xRot = -2.094f;
|
||||||
|
model.rightArm.yRot = 0.175f;
|
||||||
|
model.rightArm.zRot = 0f;
|
||||||
|
model.leftArm.xRot = -2.094f;
|
||||||
|
model.leftArm.yRot = -0.175f;
|
||||||
|
model.leftArm.zRot = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply leg pose based on pose type.
|
||||||
|
*/
|
||||||
|
private static void applyLegPose(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
PoseType poseType
|
||||||
|
) {
|
||||||
|
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||||
|
model.rightLeg.xRot = -1.047f;
|
||||||
|
model.rightLeg.yRot = 0.349f;
|
||||||
|
model.rightLeg.zRot = 0f;
|
||||||
|
model.leftLeg.xRot = -1.047f;
|
||||||
|
model.leftLeg.yRot = -0.349f;
|
||||||
|
model.leftLeg.zRot = 0f;
|
||||||
|
} else {
|
||||||
|
model.rightLeg.xRot = 0f;
|
||||||
|
model.rightLeg.yRot = 0f;
|
||||||
|
model.rightLeg.zRot = -0.1f;
|
||||||
|
model.leftLeg.xRot = 0f;
|
||||||
|
model.leftLeg.yRot = 0f;
|
||||||
|
model.leftLeg.zRot = 0.1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply body pose for DOG/HUMAN_CHAIR pose.
|
||||||
|
*/
|
||||||
|
public static void applyBodyPose(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
PoseType poseType
|
||||||
|
) {
|
||||||
|
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||||
|
model.body.xRot = -1.571f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current player/NPC posture and action state for animation selection.
|
||||||
|
* Determines which base body posture animation to play.
|
||||||
|
*
|
||||||
|
* <p>Each context maps to a GLB animation name via a prefix + variant scheme:
|
||||||
|
* <ul>
|
||||||
|
* <li>Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)</li>
|
||||||
|
* <li>Variant: "Idle" or "Struggle"</li>
|
||||||
|
* </ul>
|
||||||
|
* The {@link GlbAnimationResolver} uses these to build a fallback chain
|
||||||
|
* (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).</p>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public enum AnimationContext {
|
||||||
|
STAND_IDLE("stand_idle", false),
|
||||||
|
STAND_WALK("stand_walk", false),
|
||||||
|
STAND_SNEAK("stand_sneak", false),
|
||||||
|
STAND_STRUGGLE("stand_struggle", true),
|
||||||
|
SIT_IDLE("sit_idle", false),
|
||||||
|
SIT_STRUGGLE("sit_struggle", true),
|
||||||
|
KNEEL_IDLE("kneel_idle", false),
|
||||||
|
KNEEL_STRUGGLE("kneel_struggle", true),
|
||||||
|
|
||||||
|
// Movement style contexts
|
||||||
|
SHUFFLE_IDLE("shuffle_idle", false),
|
||||||
|
SHUFFLE_WALK("shuffle_walk", false),
|
||||||
|
HOP_IDLE("hop_idle", false),
|
||||||
|
HOP_WALK("hop_walk", false),
|
||||||
|
WADDLE_IDLE("waddle_idle", false),
|
||||||
|
WADDLE_WALK("waddle_walk", false),
|
||||||
|
CRAWL_IDLE("crawl_idle", false),
|
||||||
|
CRAWL_MOVE("crawl_move", false);
|
||||||
|
|
||||||
|
private final String animationSuffix;
|
||||||
|
private final boolean struggling;
|
||||||
|
|
||||||
|
AnimationContext(String animationSuffix, boolean struggling) {
|
||||||
|
this.animationSuffix = animationSuffix;
|
||||||
|
this.struggling = struggling;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suffix used as key for context animation JSON files (e.g., "stand_idle").
|
||||||
|
*/
|
||||||
|
public String getAnimationSuffix() {
|
||||||
|
return animationSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this context represents an active struggle state.
|
||||||
|
*/
|
||||||
|
public boolean isStruggling() {
|
||||||
|
return struggling;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GLB animation name prefix for this context's posture.
|
||||||
|
* Used by the fallback chain in {@link GlbAnimationResolver}.
|
||||||
|
*
|
||||||
|
* @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
|
||||||
|
*/
|
||||||
|
public String getGlbContextPrefix() {
|
||||||
|
return switch (this) {
|
||||||
|
case SIT_IDLE, SIT_STRUGGLE -> "Sit";
|
||||||
|
case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
|
||||||
|
case STAND_SNEAK -> "Sneak";
|
||||||
|
case STAND_WALK -> "Walk";
|
||||||
|
case STAND_IDLE, STAND_STRUGGLE -> "";
|
||||||
|
case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
|
||||||
|
case HOP_IDLE, HOP_WALK -> "Hop";
|
||||||
|
case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
|
||||||
|
case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GLB animation variant name: "Struggle" or "Idle".
|
||||||
|
*/
|
||||||
|
public String getGlbVariant() {
|
||||||
|
return switch (this) {
|
||||||
|
case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
|
||||||
|
case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
|
||||||
|
case CRAWL_MOVE -> "Move";
|
||||||
|
default -> "Idle";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.state.PetBedClientState;
|
||||||
|
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current {@link AnimationContext} for players and NPCs based on their state.
|
||||||
|
*
|
||||||
|
* <p>This is a pure function with no side effects -- it reads entity state and returns
|
||||||
|
* the appropriate animation context. The resolution priority is:
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Sitting</b> (pet bed for players, pose for NPCs) -- highest priority posture</li>
|
||||||
|
* <li><b>Kneeling</b> (NPCs only)</li>
|
||||||
|
* <li><b>Struggling</b> (standing struggle if not sitting/kneeling)</li>
|
||||||
|
* <li><b>Sneaking</b> (players only)</li>
|
||||||
|
* <li><b>Walking</b> (horizontal movement detected)</li>
|
||||||
|
* <li><b>Standing idle</b> (fallback)</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>For players, the "sitting" state is determined by the client-side pet bed cache
|
||||||
|
* ({@link PetBedClientState}) rather than entity data, since pet bed state is not
|
||||||
|
* synced via entity data accessors.</p>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class AnimationContextResolver {
|
||||||
|
|
||||||
|
private AnimationContextResolver() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the animation context for a player based on their bind state and movement.
|
||||||
|
*
|
||||||
|
* <p>Priority chain:
|
||||||
|
* <ol>
|
||||||
|
* <li>Sitting (pet bed/furniture) -- highest priority posture</li>
|
||||||
|
* <li>Struggling -- standing struggle if not sitting</li>
|
||||||
|
* <li>Movement style -- style-specific idle/walk based on movement</li>
|
||||||
|
* <li>Sneaking</li>
|
||||||
|
* <li>Walking</li>
|
||||||
|
* <li>Standing idle -- fallback</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param player the player entity (must not be null)
|
||||||
|
* @param state the player's bind state, or null if not bound
|
||||||
|
* @param activeStyle the active movement style from client state, or null
|
||||||
|
* @return the resolved animation context, never null
|
||||||
|
*/
|
||||||
|
public static AnimationContext resolve(
|
||||||
|
Player player,
|
||||||
|
@Nullable PlayerBindState state,
|
||||||
|
@Nullable MovementStyle activeStyle
|
||||||
|
) {
|
||||||
|
boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
|
||||||
|
boolean struggling = state != null && state.isStruggling();
|
||||||
|
boolean sneaking = player.isCrouching();
|
||||||
|
boolean moving =
|
||||||
|
player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||||
|
|
||||||
|
if (sitting) {
|
||||||
|
return struggling
|
||||||
|
? AnimationContext.SIT_STRUGGLE
|
||||||
|
: AnimationContext.SIT_IDLE;
|
||||||
|
}
|
||||||
|
if (struggling) {
|
||||||
|
return AnimationContext.STAND_STRUGGLE;
|
||||||
|
}
|
||||||
|
if (activeStyle != null) {
|
||||||
|
return resolveStyleContext(activeStyle, moving);
|
||||||
|
}
|
||||||
|
if (sneaking) {
|
||||||
|
return AnimationContext.STAND_SNEAK;
|
||||||
|
}
|
||||||
|
if (moving) {
|
||||||
|
return AnimationContext.STAND_WALK;
|
||||||
|
}
|
||||||
|
return AnimationContext.STAND_IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a movement style + moving flag to the appropriate AnimationContext.
|
||||||
|
*/
|
||||||
|
private static AnimationContext resolveStyleContext(
|
||||||
|
MovementStyle style,
|
||||||
|
boolean moving
|
||||||
|
) {
|
||||||
|
return switch (style) {
|
||||||
|
case SHUFFLE -> moving
|
||||||
|
? AnimationContext.SHUFFLE_WALK
|
||||||
|
: AnimationContext.SHUFFLE_IDLE;
|
||||||
|
case HOP -> moving
|
||||||
|
? AnimationContext.HOP_WALK
|
||||||
|
: AnimationContext.HOP_IDLE;
|
||||||
|
case WADDLE -> moving
|
||||||
|
? AnimationContext.WADDLE_WALK
|
||||||
|
: AnimationContext.WADDLE_IDLE;
|
||||||
|
case CRAWL -> moving
|
||||||
|
? AnimationContext.CRAWL_MOVE
|
||||||
|
: AnimationContext.CRAWL_IDLE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the animation context for a Damsel NPC based on pose and movement.
|
||||||
|
*
|
||||||
|
* <p>Unlike players, NPCs support kneeling as a distinct posture and do not sneak.</p>
|
||||||
|
*
|
||||||
|
* @param entity the damsel entity (must not be null)
|
||||||
|
* @return the resolved animation context, never null
|
||||||
|
*/
|
||||||
|
public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
|
||||||
|
boolean sitting = entity.isSitting();
|
||||||
|
boolean kneeling = entity.isKneeling();
|
||||||
|
boolean struggling = entity.isStruggling();
|
||||||
|
boolean moving =
|
||||||
|
entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||||
|
|
||||||
|
if (sitting) {
|
||||||
|
return struggling
|
||||||
|
? AnimationContext.SIT_STRUGGLE
|
||||||
|
: AnimationContext.SIT_IDLE;
|
||||||
|
}
|
||||||
|
if (kneeling) {
|
||||||
|
return struggling
|
||||||
|
? AnimationContext.KNEEL_STRUGGLE
|
||||||
|
: AnimationContext.KNEEL_IDLE;
|
||||||
|
}
|
||||||
|
if (struggling) {
|
||||||
|
return AnimationContext.STAND_STRUGGLE;
|
||||||
|
}
|
||||||
|
if (moving) {
|
||||||
|
return AnimationContext.STAND_WALK;
|
||||||
|
}
|
||||||
|
return AnimationContext.STAND_IDLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
|
||||||
|
*
|
||||||
|
* <p>Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
|
||||||
|
* registry) control the base body posture -- standing, sitting, walking, etc.
|
||||||
|
* When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
|
||||||
|
* those parts must NOT be driven by the context animation because the item's own
|
||||||
|
* GLB animation controls them instead.</p>
|
||||||
|
*
|
||||||
|
* <p>This factory loads the base context animation, creates a mutable copy, disables
|
||||||
|
* the owned parts, and builds an immutable result. Results are cached by
|
||||||
|
* {@code contextSuffix|ownedPartsHash} to avoid repeated copies.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
|
||||||
|
* called from the render thread, but the concurrent map avoids issues if
|
||||||
|
* resource reload triggers on a different thread.</p>
|
||||||
|
*
|
||||||
|
* @see AnimationContext
|
||||||
|
* @see RegionBoneMapper#computeOwnedParts
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class ContextAnimationFactory {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
private static final String NAMESPACE = "tiedup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache keyed by "contextSuffix|ownedPartsHashCode".
|
||||||
|
* Null values are stored as sentinels for missing animations to avoid repeated lookups.
|
||||||
|
*/
|
||||||
|
private static final Map<String, KeyframeAnimation> CACHE =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel set used to track cache keys where the base animation was not found,
|
||||||
|
* so we don't log the same warning repeatedly.
|
||||||
|
*/
|
||||||
|
private static final Set<String> MISSING_WARNED =
|
||||||
|
ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private ContextAnimationFactory() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or retrieve from cache) a context animation with the given parts disabled.
|
||||||
|
*
|
||||||
|
* <p>If no parts need disabling, the base animation is returned as-is (no copy needed).
|
||||||
|
* If the base animation is not found in the PlayerAnimator registry, returns null.</p>
|
||||||
|
*
|
||||||
|
* @param context the current animation context (determines which context_*.json to load)
|
||||||
|
* @param disabledParts set of PlayerAnimator part names to disable on the context layer
|
||||||
|
* (e.g., {"rightArm", "leftArm"}), typically from
|
||||||
|
* {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
|
||||||
|
* @return the context animation with disabled parts suppressed, or null if not found
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static KeyframeAnimation create(
|
||||||
|
AnimationContext context,
|
||||||
|
Set<String> disabledParts
|
||||||
|
) {
|
||||||
|
String cacheKey =
|
||||||
|
context.getAnimationSuffix() +
|
||||||
|
"|" +
|
||||||
|
String.join(",", new java.util.TreeSet<>(disabledParts));
|
||||||
|
// computeIfAbsent cannot store null values, so we handle the missing case
|
||||||
|
// by checking the MISSING_WARNED set to avoid redundant work.
|
||||||
|
KeyframeAnimation cached = CACHE.get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (MISSING_WARNED.contains(cacheKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyframeAnimation result = buildContextAnimation(
|
||||||
|
context,
|
||||||
|
disabledParts
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
CACHE.put(cacheKey, result);
|
||||||
|
} else {
|
||||||
|
MISSING_WARNED.add(cacheKey);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a context animation with the specified parts disabled.
|
||||||
|
*
|
||||||
|
* <p>Flow:
|
||||||
|
* <ol>
|
||||||
|
* <li>Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)</li>
|
||||||
|
* <li>Fall back to {@code tiedup:context_<suffix>} in PlayerAnimationRegistry (JSON-based)</li>
|
||||||
|
* <li>If no parts need disabling, return the base animation directly (immutable, shared)</li>
|
||||||
|
* <li>Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}</li>
|
||||||
|
* <li>Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}</li>
|
||||||
|
* <li>Build and return the new immutable animation</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private static KeyframeAnimation buildContextAnimation(
|
||||||
|
AnimationContext context,
|
||||||
|
Set<String> disabledParts
|
||||||
|
) {
|
||||||
|
String suffix = context.getAnimationSuffix();
|
||||||
|
|
||||||
|
// Priority 1: GLB-based context animation from ContextGlbRegistry
|
||||||
|
KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
|
||||||
|
|
||||||
|
// Priority 2: JSON-based context animation from PlayerAnimationRegistry
|
||||||
|
if (baseAnim == null) {
|
||||||
|
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
NAMESPACE,
|
||||||
|
"context_" + suffix
|
||||||
|
);
|
||||||
|
baseAnim = PlayerAnimationRegistry.getAnimation(animId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseAnim == null) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"[V2Animation] Context animation not found for suffix: {}",
|
||||||
|
suffix
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabledParts.isEmpty()) {
|
||||||
|
return baseAnim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mutable copy so we can disable parts without affecting the registry/cache original
|
||||||
|
KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
|
||||||
|
disableParts(builder, disabledParts);
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all animation axes on the specified parts.
|
||||||
|
*
|
||||||
|
* <p>Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
|
||||||
|
* by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
|
||||||
|
* all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).</p>
|
||||||
|
*
|
||||||
|
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
|
||||||
|
* includes future bone names not present in the current context animation.</p>
|
||||||
|
*/
|
||||||
|
private static void disableParts(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
Set<String> disabledParts
|
||||||
|
) {
|
||||||
|
for (String partName : disabledParts) {
|
||||||
|
KeyframeAnimation.StateCollection part = builder.getPart(partName);
|
||||||
|
if (part != null) {
|
||||||
|
part.setEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached animations. Call this on resource reload or when equipped items change
|
||||||
|
* in a way that might invalidate cached part ownership.
|
||||||
|
*/
|
||||||
|
public static void clearCache() {
|
||||||
|
CACHE.clear();
|
||||||
|
MISSING_WARNED.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.gltf.GlbParser;
|
||||||
|
import com.tiedup.remake.client.gltf.GltfData;
|
||||||
|
import com.tiedup.remake.client.gltf.GltfPoseConverter;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.packs.resources.Resource;
|
||||||
|
import net.minecraft.server.packs.resources.ResourceManager;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for context animations loaded from GLB files.
|
||||||
|
*
|
||||||
|
* <p>Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
|
||||||
|
* parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
|
||||||
|
* via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
|
||||||
|
* the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).</p>
|
||||||
|
*
|
||||||
|
* <p>GLB context animations take priority over JSON-based PlayerAnimator context
|
||||||
|
* animations. This allows artists to author posture animations directly in Blender
|
||||||
|
* instead of hand-editing JSON keyframes.</p>
|
||||||
|
*
|
||||||
|
* <p>Reloaded on resource pack reload (F3+T) via the listener registered in
|
||||||
|
* {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread safety: the registry field is a volatile reference to an unmodifiable map.
|
||||||
|
* {@link #reload} builds a new map on the reload thread then atomically swaps the
|
||||||
|
* reference, so the render thread never sees a partially populated registry.</p>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class ContextGlbRegistry {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
/** Resource directory containing context GLB files. */
|
||||||
|
private static final String DIRECTORY = "tiedup_contexts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
|
||||||
|
* Values are fully converted KeyframeAnimations with all parts enabled.
|
||||||
|
*
|
||||||
|
* <p>Volatile reference to an unmodifiable map. Reload builds a new map
|
||||||
|
* and swaps atomically; the render thread always sees a consistent snapshot.</p>
|
||||||
|
*/
|
||||||
|
private static volatile Map<String, KeyframeAnimation> REGISTRY = Map.of();
|
||||||
|
|
||||||
|
private ContextGlbRegistry() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload all context GLB files from the resource manager.
|
||||||
|
*
|
||||||
|
* <p>Scans {@code assets/<namespace>/tiedup_contexts/} for {@code .glb} files.
|
||||||
|
* Each file is parsed and converted to a full-body KeyframeAnimation.
|
||||||
|
* The context suffix is extracted from the file path:
|
||||||
|
* {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.</p>
|
||||||
|
*
|
||||||
|
* <p>GLB files without animation data or with parse errors are logged and skipped.</p>
|
||||||
|
*
|
||||||
|
* @param resourceManager the current resource manager (from reload listener)
|
||||||
|
*/
|
||||||
|
public static void reload(ResourceManager resourceManager) {
|
||||||
|
Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
|
||||||
|
|
||||||
|
Map<ResourceLocation, Resource> resources =
|
||||||
|
resourceManager.listResources(DIRECTORY, loc ->
|
||||||
|
loc.getPath().endsWith(".glb")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (Map.Entry<
|
||||||
|
ResourceLocation,
|
||||||
|
Resource
|
||||||
|
> entry : resources.entrySet()) {
|
||||||
|
ResourceLocation loc = entry.getKey();
|
||||||
|
Resource resource = entry.getValue();
|
||||||
|
|
||||||
|
// Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
|
||||||
|
String path = loc.getPath();
|
||||||
|
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
|
||||||
|
|
||||||
|
try (InputStream is = resource.open()) {
|
||||||
|
GltfData data = GlbParser.parse(is, loc.toString());
|
||||||
|
|
||||||
|
// Convert to a full-body KeyframeAnimation (all parts enabled)
|
||||||
|
KeyframeAnimation anim = GltfPoseConverter.convert(data);
|
||||||
|
newRegistry.put(suffix, anim);
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'",
|
||||||
|
loc,
|
||||||
|
suffix
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error(
|
||||||
|
"[GltfPipeline] Failed to load context GLB: {}",
|
||||||
|
loc,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap: render thread never sees a partially populated registry
|
||||||
|
REGISTRY = Collections.unmodifiableMap(newRegistry);
|
||||||
|
LOGGER.info(
|
||||||
|
"[ContextGlb] Loaded {} context GLB animations",
|
||||||
|
newRegistry.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a context animation by suffix.
|
||||||
|
*
|
||||||
|
* @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
|
||||||
|
* @return the KeyframeAnimation, or null if no GLB was found for this suffix
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static KeyframeAnimation get(String contextSuffix) {
|
||||||
|
return REGISTRY.get(contextSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached context animations.
|
||||||
|
* Called on resource reload and world unload.
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
REGISTRY = Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.gltf.GltfCache;
|
||||||
|
import com.tiedup.remake.client.gltf.GltfData;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which named animation to play from a GLB file based on the current
|
||||||
|
* {@link AnimationContext}. Implements three features:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Context-based resolution with fallback chain</b> — tries progressively
|
||||||
|
* less specific animation names until one is found:
|
||||||
|
* <pre>SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null</pre></li>
|
||||||
|
* <li><b>Animation variants</b> — if {@code Struggle.1}, {@code Struggle.2},
|
||||||
|
* {@code Struggle.3} exist in the GLB, one is picked at random each time</li>
|
||||||
|
* <li><b>Shared animation templates</b> — animations can come from a separate GLB
|
||||||
|
* file (passed as {@code animationSource} to {@link #resolveAnimationData})</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>This class is stateless and thread-safe. All methods are static.</p>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GlbAnimationResolver {
|
||||||
|
|
||||||
|
private GlbAnimationResolver() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the animation data source.
|
||||||
|
* If {@code animationSource} is non-null, load that GLB for animations
|
||||||
|
* (shared template). Otherwise use the item's own model GLB.
|
||||||
|
*
|
||||||
|
* @param itemModelLoc the item's GLB model resource location
|
||||||
|
* @param animationSource optional separate GLB containing shared animations
|
||||||
|
* @return parsed GLB data, or null if loading failed
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static GltfData resolveAnimationData(
|
||||||
|
ResourceLocation itemModelLoc,
|
||||||
|
@Nullable ResourceLocation animationSource
|
||||||
|
) {
|
||||||
|
ResourceLocation source =
|
||||||
|
animationSource != null ? animationSource : itemModelLoc;
|
||||||
|
return GltfCache.get(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the best animation name from a GLB for the given context.
|
||||||
|
* Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
|
||||||
|
* and full-body animations ({@code FullWalk}, {@code FullStruggle}).
|
||||||
|
*
|
||||||
|
* <p>Fallback chain (Full variants checked first at each step):</p>
|
||||||
|
* <pre>
|
||||||
|
* FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
|
||||||
|
* -> FullSitIdle -> SitIdle -> FullSit -> Sit
|
||||||
|
* -> FullIdle -> Idle -> null
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param data the parsed GLB data containing named animations
|
||||||
|
* @param context the current animation context (posture + action)
|
||||||
|
* @return the animation name to use, or null to use the default (first) clip
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static String resolve(GltfData data, AnimationContext context) {
|
||||||
|
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
|
||||||
|
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
|
||||||
|
|
||||||
|
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
|
||||||
|
String exact = prefix + variant;
|
||||||
|
if (!exact.isEmpty()) {
|
||||||
|
String picked = pickWithVariants(data, "Full" + exact);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
picked = pickWithVariants(data, exact);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
|
||||||
|
if (context.isStruggling()) {
|
||||||
|
String picked = pickWithVariants(data, "FullStruggle");
|
||||||
|
if (picked != null) return picked;
|
||||||
|
picked = pickWithVariants(data, "Struggle");
|
||||||
|
if (picked != null) return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Context-only: "FullSit" then "Sit" (with variants)
|
||||||
|
if (!prefix.isEmpty()) {
|
||||||
|
String picked = pickWithVariants(data, "Full" + prefix);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
picked = pickWithVariants(data, prefix);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Variant-only: "FullIdle" then "Idle" (with variants)
|
||||||
|
{
|
||||||
|
String picked = pickWithVariants(data, "Full" + variant);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
picked = pickWithVariants(data, variant);
|
||||||
|
if (picked != null) return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Default: return null = use first animation clip in GLB
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look for an animation by base name, including numbered variants.
|
||||||
|
* <ul>
|
||||||
|
* <li>If "Struggle" exists alone, return "Struggle"</li>
|
||||||
|
* <li>If "Struggle.1" and "Struggle.2" exist, pick one randomly</li>
|
||||||
|
* <li>If both "Struggle" and "Struggle.1" exist, include all in the random pool</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Variant numbering starts at 1 and tolerates a missing {@code .1}
|
||||||
|
* (continues to check {@code .2}). Gaps after index 1 stop the scan.
|
||||||
|
* For example, {@code Struggle.1, Struggle.3} would only find
|
||||||
|
* {@code Struggle.1} because the gap at index 2 stops iteration.
|
||||||
|
* However, if only {@code Struggle.2} exists (no {@code .1}), it will
|
||||||
|
* still be found because the scan skips the first gap.</p>
|
||||||
|
*
|
||||||
|
* @param data the parsed GLB data
|
||||||
|
* @param baseName the base animation name (e.g., "Struggle", "SitIdle")
|
||||||
|
* @return the selected animation name, or null if no match found
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private static String pickWithVariants(GltfData data, String baseName) {
|
||||||
|
Map<String, GltfData.AnimationClip> anims = data.namedAnimations();
|
||||||
|
List<String> candidates = new ArrayList<>();
|
||||||
|
|
||||||
|
if (anims.containsKey(baseName)) {
|
||||||
|
candidates.add(baseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check numbered variants: baseName.1, baseName.2, ...
|
||||||
|
for (int i = 1; i <= 99; i++) {
|
||||||
|
String variantName = baseName + "." + i;
|
||||||
|
if (anims.containsKey(variantName)) {
|
||||||
|
candidates.add(variantName);
|
||||||
|
} else if (i > 1) {
|
||||||
|
break; // Stop at first gap after .1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) return null;
|
||||||
|
if (candidates.size() == 1) return candidates.get(0);
|
||||||
|
return candidates.get(
|
||||||
|
ThreadLocalRandom.current().nextInt(candidates.size())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package com.tiedup.remake.client.animation.context;
|
||||||
|
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||||
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||||
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||||
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||||
|
import java.util.*;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps V2 body regions to PlayerAnimator part names.
|
||||||
|
* Bridge between gameplay regions and animation bones.
|
||||||
|
*
|
||||||
|
* <p>PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
|
||||||
|
* This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
|
||||||
|
* enabling the animation system to know which bones are "owned" by equipped bondage items.</p>
|
||||||
|
*
|
||||||
|
* <p>Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
|
||||||
|
* These regions still affect gameplay (blocking, escape difficulty) but don't directly
|
||||||
|
* constrain animation bones.</p>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class RegionBoneMapper {
|
||||||
|
|
||||||
|
/** All PlayerAnimator part names for the player model. */
|
||||||
|
public static final Set<String> ALL_PARTS = Set.of(
|
||||||
|
"head",
|
||||||
|
"body",
|
||||||
|
"rightArm",
|
||||||
|
"leftArm",
|
||||||
|
"rightLeg",
|
||||||
|
"leftLeg"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes bone ownership for a specific item in the context of all equipped items.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code thisParts} — parts owned exclusively by the winning item</li>
|
||||||
|
* <li>{@code otherParts} — parts owned by other equipped items</li>
|
||||||
|
* <li>{@link #freeParts()} — parts not owned by any item (available for animation)</li>
|
||||||
|
* <li>{@link #enabledParts()} — parts the winning item may animate (owned + free)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>When both the winning item and another item claim the same bone,
|
||||||
|
* the other item takes precedence (the bone goes to {@code otherParts}).</p>
|
||||||
|
*/
|
||||||
|
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
|
||||||
|
/**
|
||||||
|
* Parts not owned by any item. These are "free" and can be animated
|
||||||
|
* by the winning item IF the GLB contains keyframes for them.
|
||||||
|
*/
|
||||||
|
public Set<String> freeParts() {
|
||||||
|
Set<String> free = new HashSet<>(ALL_PARTS);
|
||||||
|
free.removeAll(thisParts);
|
||||||
|
free.removeAll(otherParts);
|
||||||
|
return Collections.unmodifiableSet(free);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parts the winning item is allowed to animate: its own parts + free parts.
|
||||||
|
* Free parts are only actually enabled if the GLB has keyframes for them.
|
||||||
|
*/
|
||||||
|
public Set<String> enabledParts() {
|
||||||
|
Set<String> enabled = new HashSet<>(thisParts);
|
||||||
|
enabled.addAll(freeParts());
|
||||||
|
return Collections.unmodifiableSet(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parts that must be disabled on the context layer: parts owned by this item
|
||||||
|
* (handled by item layer) + parts owned by other items (handled by their layer).
|
||||||
|
* This equals ALL_PARTS minus freeParts.
|
||||||
|
*/
|
||||||
|
public Set<String> disabledOnContext() {
|
||||||
|
Set<String> disabled = new HashSet<>(thisParts);
|
||||||
|
disabled.addAll(otherParts);
|
||||||
|
return Collections.unmodifiableSet(disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<BodyRegionV2, Set<String>> REGION_TO_PARTS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<BodyRegionV2, Set<String>> map = new EnumMap<>(BodyRegionV2.class);
|
||||||
|
map.put(BodyRegionV2.HEAD, Set.of("head"));
|
||||||
|
map.put(BodyRegionV2.EYES, Set.of("head"));
|
||||||
|
map.put(BodyRegionV2.EARS, Set.of("head"));
|
||||||
|
map.put(BodyRegionV2.MOUTH, Set.of("head"));
|
||||||
|
map.put(BodyRegionV2.NECK, Set.of());
|
||||||
|
map.put(BodyRegionV2.TORSO, Set.of("body"));
|
||||||
|
map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm"));
|
||||||
|
map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm"));
|
||||||
|
map.put(BodyRegionV2.FINGERS, Set.of());
|
||||||
|
map.put(BodyRegionV2.WAIST, Set.of("body"));
|
||||||
|
map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg"));
|
||||||
|
map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg"));
|
||||||
|
map.put(BodyRegionV2.TAIL, Set.of());
|
||||||
|
map.put(BodyRegionV2.WINGS, Set.of());
|
||||||
|
REGION_TO_PARTS = Collections.unmodifiableMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RegionBoneMapper() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PlayerAnimator part names affected by a single body region.
|
||||||
|
*
|
||||||
|
* @param region the V2 body region
|
||||||
|
* @return unmodifiable set of part name strings, never null (may be empty)
|
||||||
|
*/
|
||||||
|
public static Set<String> getPartsForRegion(BodyRegionV2 region) {
|
||||||
|
return REGION_TO_PARTS.getOrDefault(region, Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the union of all PlayerAnimator parts "owned" by equipped bondage items.
|
||||||
|
*
|
||||||
|
* <p>Iterates over the equipped map (as returned by
|
||||||
|
* {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()})
|
||||||
|
* and collects every bone affected by each item's occupied regions.</p>
|
||||||
|
*
|
||||||
|
* @param equipped map from representative region to equipped ItemStack
|
||||||
|
* @return unmodifiable set of owned part name strings
|
||||||
|
*/
|
||||||
|
public static Set<String> computeOwnedParts(
|
||||||
|
Map<BodyRegionV2, ItemStack> equipped
|
||||||
|
) {
|
||||||
|
Set<String> owned = new HashSet<>();
|
||||||
|
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||||
|
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||||
|
owned.addAll(getPartsForRegion(region));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableSet(owned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute per-item bone ownership for a specific "winning" item.
|
||||||
|
*
|
||||||
|
* <p>Iterates over all equipped items. Parts owned by the winning item
|
||||||
|
* go to {@code thisParts}; parts owned by other items go to {@code otherParts}.
|
||||||
|
* If both the winning item and another item claim the same bone, the other
|
||||||
|
* item takes precedence (conflict resolution: other wins).</p>
|
||||||
|
*
|
||||||
|
* <p>Uses ItemStack reference equality ({@code ==}) to identify the winning item
|
||||||
|
* because the same ItemStack instance is used in the equipped map.</p>
|
||||||
|
*
|
||||||
|
* @param equipped map from representative region to equipped ItemStack
|
||||||
|
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
|
||||||
|
* @return BoneOwnership describing this item's parts vs other items' parts
|
||||||
|
*/
|
||||||
|
public static BoneOwnership computePerItemParts(
|
||||||
|
Map<BodyRegionV2, ItemStack> equipped,
|
||||||
|
ItemStack winningItemStack
|
||||||
|
) {
|
||||||
|
Set<String> thisParts = new HashSet<>();
|
||||||
|
Set<String> otherParts = new HashSet<>();
|
||||||
|
|
||||||
|
// Track which ItemStacks we've already processed to avoid duplicate work
|
||||||
|
// (multiple regions can map to the same ItemStack)
|
||||||
|
Set<ItemStack> processed = Collections.newSetFromMap(
|
||||||
|
new IdentityHashMap<>()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
if (processed.contains(stack)) continue;
|
||||||
|
processed.add(stack);
|
||||||
|
|
||||||
|
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||||
|
Set<String> itemParts = new HashSet<>();
|
||||||
|
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||||
|
itemParts.addAll(getPartsForRegion(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack == winningItemStack) {
|
||||||
|
thisParts.addAll(itemParts);
|
||||||
|
} else {
|
||||||
|
otherParts.addAll(itemParts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict resolution: if both this item and another claim the same bone,
|
||||||
|
// the other item takes precedence
|
||||||
|
thisParts.removeAll(otherParts);
|
||||||
|
|
||||||
|
return new BoneOwnership(
|
||||||
|
Collections.unmodifiableSet(thisParts),
|
||||||
|
Collections.unmodifiableSet(otherParts)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of resolving the highest-priority V2 item with a GLB model.
|
||||||
|
* Combines the model location, optional animation source, and the winning ItemStack
|
||||||
|
* into a single object so callers don't need two separate iteration passes.
|
||||||
|
*
|
||||||
|
* @param modelLoc the GLB model ResourceLocation of the winning item
|
||||||
|
* @param animSource separate GLB for animations (shared template), or null to use modelLoc
|
||||||
|
* @param winningItem the actual ItemStack reference (for identity comparison in
|
||||||
|
* {@link #computePerItemParts})
|
||||||
|
*/
|
||||||
|
public record GlbModelResult(
|
||||||
|
ResourceLocation modelLoc,
|
||||||
|
@Nullable ResourceLocation animSource,
|
||||||
|
ItemStack winningItem
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation info for a single equipped V2 item.
|
||||||
|
* Used by the multi-item animation pipeline to process each item independently.
|
||||||
|
*
|
||||||
|
* @param modelLoc GLB model location (for rendering + default animation source)
|
||||||
|
* @param animSource separate animation GLB, or null to use modelLoc
|
||||||
|
* @param ownedParts parts this item exclusively owns (after conflict resolution)
|
||||||
|
* @param posePriority the item's pose priority (for free-bone assignment)
|
||||||
|
* @param animationBones per-animation bone whitelist from the data-driven definition.
|
||||||
|
* Empty map for hardcoded items (no filtering applied).
|
||||||
|
*/
|
||||||
|
public record V2ItemAnimInfo(
|
||||||
|
ResourceLocation modelLoc,
|
||||||
|
@Nullable ResourceLocation animSource,
|
||||||
|
Set<String> ownedParts,
|
||||||
|
int posePriority,
|
||||||
|
Map<String, Set<String>> animationBones
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the highest-priority V2 item with a GLB model in the equipped map.
|
||||||
|
*
|
||||||
|
* <p>Single pass over all equipped items, comparing their
|
||||||
|
* {@link IV2BondageItem#getPosePriority()} to select the dominant model.
|
||||||
|
* Returns both the model location and the winning ItemStack reference so
|
||||||
|
* callers can pass the ItemStack to {@link #computePerItemParts} without
|
||||||
|
* a second iteration.</p>
|
||||||
|
*
|
||||||
|
* @param equipped map of equipped V2 items by body region (may be empty, never null)
|
||||||
|
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static GlbModelResult resolveWinningItem(
|
||||||
|
Map<BodyRegionV2, ItemStack> equipped
|
||||||
|
) {
|
||||||
|
ItemStack bestStack = null;
|
||||||
|
ResourceLocation bestModel = null;
|
||||||
|
int bestPriority = Integer.MIN_VALUE;
|
||||||
|
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||||
|
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||||
|
if (
|
||||||
|
model != null &&
|
||||||
|
v2Item.getPosePriority(stack) > bestPriority
|
||||||
|
) {
|
||||||
|
bestPriority = v2Item.getPosePriority(stack);
|
||||||
|
bestModel = model;
|
||||||
|
bestStack = stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestStack == null || bestModel == null) return null;
|
||||||
|
|
||||||
|
// Extract animation source from data-driven item definitions.
|
||||||
|
// For hardcoded IV2BondageItem implementations, animSource stays null
|
||||||
|
// (the model's own animations are used).
|
||||||
|
ResourceLocation animSource = null;
|
||||||
|
if (bestStack.getItem() instanceof DataDrivenBondageItem) {
|
||||||
|
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(
|
||||||
|
bestStack
|
||||||
|
);
|
||||||
|
if (def != null) {
|
||||||
|
animSource = def.animationSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GlbModelResult(bestModel, animSource, bestStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ALL equipped V2 items with GLB models, with per-item bone ownership.
|
||||||
|
*
|
||||||
|
* <p>Each item gets ownership of its declared regions' bones. When two items claim
|
||||||
|
* the same bone, the higher-priority item wins. The highest-priority item is also
|
||||||
|
* designated as the "free bone donor" — it can animate free bones if its GLB has
|
||||||
|
* keyframes for them.</p>
|
||||||
|
*
|
||||||
|
* @param equipped map from representative region to equipped ItemStack
|
||||||
|
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
|
||||||
|
* The first element (if any) is the free-bone donor.
|
||||||
|
*/
|
||||||
|
public static List<V2ItemAnimInfo> resolveAllV2Items(
|
||||||
|
Map<BodyRegionV2, ItemStack> equipped
|
||||||
|
) {
|
||||||
|
record ItemEntry(
|
||||||
|
ItemStack stack,
|
||||||
|
IV2BondageItem v2Item,
|
||||||
|
ResourceLocation model,
|
||||||
|
@Nullable ResourceLocation animSource,
|
||||||
|
Set<String> rawParts,
|
||||||
|
int priority,
|
||||||
|
Map<String, Set<String>> animationBones
|
||||||
|
) {}
|
||||||
|
|
||||||
|
List<ItemEntry> entries = new ArrayList<>();
|
||||||
|
Set<ItemStack> seen = Collections.newSetFromMap(
|
||||||
|
new IdentityHashMap<>()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
if (seen.contains(stack)) continue;
|
||||||
|
seen.add(stack);
|
||||||
|
|
||||||
|
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||||
|
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||||
|
if (model == null) continue;
|
||||||
|
|
||||||
|
Set<String> rawParts = new HashSet<>();
|
||||||
|
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||||
|
rawParts.addAll(getPartsForRegion(region));
|
||||||
|
}
|
||||||
|
if (rawParts.isEmpty()) continue;
|
||||||
|
|
||||||
|
ResourceLocation animSource = null;
|
||||||
|
Map<String, Set<String>> animBones = Map.of();
|
||||||
|
if (stack.getItem() instanceof DataDrivenBondageItem) {
|
||||||
|
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(
|
||||||
|
stack
|
||||||
|
);
|
||||||
|
if (def != null) {
|
||||||
|
animSource = def.animationSource();
|
||||||
|
animBones = def.animationBones();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.add(
|
||||||
|
new ItemEntry(
|
||||||
|
stack,
|
||||||
|
v2Item,
|
||||||
|
model,
|
||||||
|
animSource,
|
||||||
|
rawParts,
|
||||||
|
v2Item.getPosePriority(stack),
|
||||||
|
animBones
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
entries.sort((a, b) -> Integer.compare(b.priority(), a.priority()));
|
||||||
|
|
||||||
|
Set<String> claimed = new HashSet<>();
|
||||||
|
List<V2ItemAnimInfo> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ItemEntry e : entries) {
|
||||||
|
Set<String> ownedParts = new HashSet<>(e.rawParts());
|
||||||
|
ownedParts.removeAll(claimed);
|
||||||
|
if (ownedParts.isEmpty()) continue;
|
||||||
|
claimed.addAll(ownedParts);
|
||||||
|
result.add(
|
||||||
|
new V2ItemAnimInfo(
|
||||||
|
e.model(),
|
||||||
|
e.animSource(),
|
||||||
|
Collections.unmodifiableSet(ownedParts),
|
||||||
|
e.priority(),
|
||||||
|
e.animationBones()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Collections.unmodifiableList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the set of all bone parts owned by any item in the resolved list.
|
||||||
|
* Used to disable owned parts on the context layer.
|
||||||
|
*/
|
||||||
|
public static Set<String> computeAllOwnedParts(List<V2ItemAnimInfo> items) {
|
||||||
|
Set<String> allOwned = new HashSet<>();
|
||||||
|
for (V2ItemAnimInfo item : items) {
|
||||||
|
allOwned.addAll(item.ownedParts());
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableSet(allOwned);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import com.tiedup.remake.state.HumanChairHelper;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.EventPriority;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles DOG and HUMAN_CHAIR pose rendering adjustments.
|
||||||
|
*
|
||||||
|
* <p>Applies vertical offset and smooth body rotation for DOG/HUMAN_CHAIR poses.
|
||||||
|
* Runs at HIGH priority to ensure transforms are applied before other Pre handlers.
|
||||||
|
*
|
||||||
|
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class DogPoseRenderHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOG pose state tracking per player.
|
||||||
|
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
|
||||||
|
*/
|
||||||
|
private static final Int2ObjectMap<float[]> dogPoseState =
|
||||||
|
new Int2ObjectOpenHashMap<>();
|
||||||
|
|
||||||
|
// Array indices for dogPoseState
|
||||||
|
private static final int IDX_TARGET = 0;
|
||||||
|
private static final int IDX_CURRENT = 1;
|
||||||
|
private static final int IDX_DELTA = 2;
|
||||||
|
private static final int IDX_MOVING = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rotation delta applied to a player's render for DOG pose.
|
||||||
|
* Used by MixinPlayerModel to compensate head rotation.
|
||||||
|
*/
|
||||||
|
public static float getAppliedRotationDelta(int playerId) {
|
||||||
|
float[] state = dogPoseState.get(playerId);
|
||||||
|
return state != null ? state[IDX_DELTA] : 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a player is currently moving in DOG pose.
|
||||||
|
*/
|
||||||
|
public static boolean isDogPoseMoving(int playerId) {
|
||||||
|
float[] state = dogPoseState.get(playerId);
|
||||||
|
return state != null && state[IDX_MOVING] > 0.5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all DOG pose state data.
|
||||||
|
* Called on world unload to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
public static void clearState() {
|
||||||
|
dogPoseState.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
|
||||||
|
* HIGH priority ensures this runs before arm/item hiding handlers.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||||
|
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.isRemoved() || !player.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (
|
||||||
|
bindForPose.isEmpty() ||
|
||||||
|
!(bindForPose.getItem() instanceof ItemBind itemBind)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PoseType bindPoseType = itemBind.getPoseType();
|
||||||
|
// Check for humanChairMode NBT override
|
||||||
|
bindPoseType = HumanChairHelper.resolveEffectivePose(
|
||||||
|
bindPoseType,
|
||||||
|
bindForPose
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
bindPoseType != PoseType.DOG && bindPoseType != PoseType.HUMAN_CHAIR
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower player by 6 model units (6/16 = 0.375 blocks)
|
||||||
|
event
|
||||||
|
.getPoseStack()
|
||||||
|
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||||
|
|
||||||
|
int playerId = player.getId();
|
||||||
|
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
|
||||||
|
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
|
||||||
|
|
||||||
|
// Get or create state - initialize to current body rotation
|
||||||
|
float[] s = dogPoseState.get(playerId);
|
||||||
|
if (s == null) {
|
||||||
|
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
|
||||||
|
dogPoseState.put(playerId, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human chair: lock rotation state — body must not turn
|
||||||
|
if (bindPoseType == PoseType.HUMAN_CHAIR) {
|
||||||
|
s[IDX_CURRENT] = player.yBodyRot;
|
||||||
|
s[IDX_TARGET] = player.yBodyRot;
|
||||||
|
s[IDX_DELTA] = 0f;
|
||||||
|
s[IDX_MOVING] = 0f;
|
||||||
|
} else {
|
||||||
|
// Determine target rotation
|
||||||
|
float rawTarget;
|
||||||
|
if (isMoving) {
|
||||||
|
// Moving: face movement direction
|
||||||
|
rawTarget = (float) Math.toDegrees(
|
||||||
|
Math.atan2(-movement.x, movement.z)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Stationary: face where head is looking
|
||||||
|
rawTarget = player.yHeadRot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if head would be clamped (body lagging behind head)
|
||||||
|
float predictedHeadYaw = net.minecraft.util.Mth.wrapDegrees(
|
||||||
|
player.yHeadRot - s[IDX_CURRENT]
|
||||||
|
);
|
||||||
|
float maxYaw = isMoving
|
||||||
|
? RenderConstants.HEAD_MAX_YAW_MOVING
|
||||||
|
: RenderConstants.HEAD_MAX_YAW_STATIONARY;
|
||||||
|
boolean headAtLimit =
|
||||||
|
Math.abs(predictedHeadYaw) >
|
||||||
|
maxYaw * RenderConstants.HEAD_AT_LIMIT_RATIO;
|
||||||
|
|
||||||
|
if (headAtLimit && !isMoving) {
|
||||||
|
// Head at limit while stationary: snap body to release head
|
||||||
|
float sign = predictedHeadYaw > 0 ? 1f : -1f;
|
||||||
|
s[IDX_CURRENT] =
|
||||||
|
player.yHeadRot -
|
||||||
|
sign * maxYaw * RenderConstants.HEAD_SNAP_RELEASE_RATIO;
|
||||||
|
s[IDX_TARGET] = s[IDX_CURRENT];
|
||||||
|
} else {
|
||||||
|
// Normal smoothing
|
||||||
|
float targetDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||||
|
rawTarget - s[IDX_TARGET]
|
||||||
|
);
|
||||||
|
float targetSpeed = isMoving
|
||||||
|
? RenderConstants.DOG_TARGET_SPEED_MOVING
|
||||||
|
: RenderConstants.DOG_TARGET_SPEED_STATIONARY;
|
||||||
|
s[IDX_TARGET] += targetDelta * targetSpeed;
|
||||||
|
|
||||||
|
float rotDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||||
|
s[IDX_TARGET] - s[IDX_CURRENT]
|
||||||
|
);
|
||||||
|
float speed = isMoving
|
||||||
|
? RenderConstants.DOG_ROT_SPEED_MOVING
|
||||||
|
: RenderConstants.DOG_ROT_SPEED_STATIONARY;
|
||||||
|
s[IDX_CURRENT] += rotDelta * speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate and store the delta we apply to poseStack
|
||||||
|
s[IDX_DELTA] = player.yBodyRot - s[IDX_CURRENT];
|
||||||
|
s[IDX_MOVING] = isMoving ? 1f : 0f;
|
||||||
|
|
||||||
|
// Apply rotation to make body face our custom direction
|
||||||
|
event
|
||||||
|
.getPoseStack()
|
||||||
|
.mulPose(com.mojang.math.Axis.YP.rotationDegrees(s[IDX_DELTA]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderHandEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide first-person hand/item rendering based on bondage state.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Tied up: Hide hands completely (hands are behind back)
|
||||||
|
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class FirstPersonHandHideHandler {
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderHand(RenderHandEvent event) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocalPlayer player = mc.player;
|
||||||
|
|
||||||
|
if (player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tied or Mittens: hide hands completely
|
||||||
|
// (Forge limitation: RenderHandEvent controls hand + item together)
|
||||||
|
if (state.isTiedUp() || state.hasMittens()) {
|
||||||
|
event.setCanceled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides held items when player has arms bound or is wearing mittens.
|
||||||
|
*
|
||||||
|
* <p>Uses Pre/Post pattern to temporarily replace held items with empty
|
||||||
|
* stacks for rendering, then restore them after.
|
||||||
|
*
|
||||||
|
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class HeldItemHideHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored items to restore after rendering.
|
||||||
|
* Key: Player entity ID (int), Value: [mainHand, offHand]
|
||||||
|
*/
|
||||||
|
private static final Int2ObjectMap<ItemStack[]> storedItems =
|
||||||
|
new Int2ObjectOpenHashMap<>();
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.isRemoved() || !player.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasArmsBound = state.hasArmsBound();
|
||||||
|
boolean hasMittens = state.hasMittens();
|
||||||
|
|
||||||
|
if (hasArmsBound || hasMittens) {
|
||||||
|
ItemStack mainHand = player.getItemInHand(
|
||||||
|
InteractionHand.MAIN_HAND
|
||||||
|
);
|
||||||
|
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
|
||||||
|
|
||||||
|
if (!mainHand.isEmpty() || !offHand.isEmpty()) {
|
||||||
|
storedItems.put(
|
||||||
|
player.getId(),
|
||||||
|
new ItemStack[] { mainHand.copy(), offHand.copy() }
|
||||||
|
);
|
||||||
|
|
||||||
|
player.setItemInHand(
|
||||||
|
InteractionHand.MAIN_HAND,
|
||||||
|
ItemStack.EMPTY
|
||||||
|
);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack[] items = storedItems.remove(player.getId());
|
||||||
|
if (items != null) {
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, items[0]);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.state.PetBedClientState;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import com.tiedup.remake.state.HumanChairHelper;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.EventPriority;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles pet bed render adjustments (SIT and SLEEP modes).
|
||||||
|
*
|
||||||
|
* <p>Applies vertical offset and forced standing pose for pet bed states.
|
||||||
|
* Runs at HIGH priority alongside DogPoseRenderHandler.
|
||||||
|
*
|
||||||
|
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class PetBedRenderHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before player render: Apply vertical offset and forced pose for pet bed.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||||
|
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.isRemoved() || !player.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.UUID petBedUuid = player.getUUID();
|
||||||
|
byte petBedMode = PetBedClientState.get(petBedUuid);
|
||||||
|
|
||||||
|
if (petBedMode == 1 || petBedMode == 2) {
|
||||||
|
// Skip Y-offset if DogPoseRenderHandler already applies it
|
||||||
|
// (DOG/HUMAN_CHAIR pose uses the same offset amount)
|
||||||
|
if (!isDogOrChairPose(player)) {
|
||||||
|
event
|
||||||
|
.getPoseStack()
|
||||||
|
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (petBedMode == 2) {
|
||||||
|
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
|
||||||
|
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
|
||||||
|
|
||||||
|
// Compensate for vanilla sleeping Y offset
|
||||||
|
player
|
||||||
|
.getSleepingPos()
|
||||||
|
.ifPresent(pos -> {
|
||||||
|
double yOffset = player.getY() - pos.getY();
|
||||||
|
if (yOffset > 0.01) {
|
||||||
|
event.getPoseStack().translate(0, -yOffset, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the player is in DOG or HUMAN_CHAIR pose.
|
||||||
|
* Used to avoid double Y-offset with DogPoseRenderHandler.
|
||||||
|
*/
|
||||||
|
private static boolean isDogOrChairPose(Player player) {
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) return false;
|
||||||
|
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (
|
||||||
|
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
|
||||||
|
) return false;
|
||||||
|
PoseType pose = HumanChairHelper.resolveEffectivePose(
|
||||||
|
itemBind.getPoseType(),
|
||||||
|
bind
|
||||||
|
);
|
||||||
|
return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After player render: Restore forced pose for pet bed SLEEP mode.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte petBedMode = PetBedClientState.get(player.getUUID());
|
||||||
|
if (petBedMode == 2) {
|
||||||
|
player.setForcedPose(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import com.tiedup.remake.items.clothes.ClothesProperties;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||||
|
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||||
|
import net.minecraft.client.model.PlayerModel;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide player arms and outer layers based on bondage/clothes state.
|
||||||
|
*
|
||||||
|
* <p>Responsibilities (after extraction of dog pose, pet bed, and held items):
|
||||||
|
* <ul>
|
||||||
|
* <li>Hide arms for wrap/latex_sack poses</li>
|
||||||
|
* <li>Hide outer layers (hat, jacket, sleeves, pants) based on clothes settings</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Uses Pre/Post pattern to temporarily modify and restore state.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class PlayerArmHideEventHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored layer visibility to restore after rendering.
|
||||||
|
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
|
||||||
|
*/
|
||||||
|
private static final Int2ObjectMap<boolean[]> storedLayers =
|
||||||
|
new Int2ObjectOpenHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before player render:
|
||||||
|
* - Hide arms for wrap/latex_sack poses
|
||||||
|
* - Hide outer layers based on clothes settings
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.isRemoved() || !player.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerModel<?> model = event.getRenderer().getModel();
|
||||||
|
|
||||||
|
// === HIDE ARMS (wrap/latex_sack poses) ===
|
||||||
|
if (state.hasArmsBound()) {
|
||||||
|
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (
|
||||||
|
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind
|
||||||
|
) {
|
||||||
|
PoseType poseType = itemBind.getPoseType();
|
||||||
|
|
||||||
|
// Only hide arms for wrap/sack poses (arms are covered by the item)
|
||||||
|
if (
|
||||||
|
poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK
|
||||||
|
) {
|
||||||
|
model.leftArm.visible = false;
|
||||||
|
model.rightArm.visible = false;
|
||||||
|
model.leftSleeve.visible = false;
|
||||||
|
model.rightSleeve.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HIDE WEARER LAYERS (clothes settings) ===
|
||||||
|
ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO);
|
||||||
|
if (!clothes.isEmpty()) {
|
||||||
|
ClothesProperties props =
|
||||||
|
ClothesRenderHelper.getPropsForLayerHiding(
|
||||||
|
clothes,
|
||||||
|
clientPlayer
|
||||||
|
);
|
||||||
|
if (props != null) {
|
||||||
|
boolean[] savedLayers = ClothesRenderHelper.hideWearerLayers(
|
||||||
|
model,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
if (savedLayers != null) {
|
||||||
|
storedLayers.put(player.getId(), savedLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After player render: Restore arm visibility and layer visibility.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||||
|
Player player = event.getEntity();
|
||||||
|
if (!(player instanceof AbstractClientPlayer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerModel<?> model = event.getRenderer().getModel();
|
||||||
|
|
||||||
|
// === RESTORE ARM VISIBILITY ===
|
||||||
|
model.leftArm.visible = true;
|
||||||
|
model.rightArm.visible = true;
|
||||||
|
model.leftSleeve.visible = true;
|
||||||
|
model.rightSleeve.visible = true;
|
||||||
|
|
||||||
|
// === RESTORE WEARER LAYERS ===
|
||||||
|
boolean[] savedLayers = storedLayers.remove(player.getId());
|
||||||
|
if (savedLayers != null) {
|
||||||
|
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.tiedup.remake.client.animation.render;
|
||||||
|
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralizes magic numbers used across render handlers.
|
||||||
|
*
|
||||||
|
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
|
||||||
|
* that were previously scattered as unnamed literals.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class RenderConstants {
|
||||||
|
|
||||||
|
private RenderConstants() {}
|
||||||
|
|
||||||
|
// === DOG pose rotation smoothing speeds ===
|
||||||
|
|
||||||
|
/** Speed for smoothing body rotation toward target while moving */
|
||||||
|
public static final float DOG_ROT_SPEED_MOVING = 0.15f;
|
||||||
|
|
||||||
|
/** Speed for smoothing body rotation toward target while stationary */
|
||||||
|
public static final float DOG_ROT_SPEED_STATIONARY = 0.12f;
|
||||||
|
|
||||||
|
/** Speed for smoothing target rotation while moving */
|
||||||
|
public static final float DOG_TARGET_SPEED_MOVING = 0.2f;
|
||||||
|
|
||||||
|
/** Speed for smoothing target rotation while stationary */
|
||||||
|
public static final float DOG_TARGET_SPEED_STATIONARY = 0.3f;
|
||||||
|
|
||||||
|
// === Head clamp limits ===
|
||||||
|
|
||||||
|
/** Maximum head yaw relative to body while moving (degrees) */
|
||||||
|
public static final float HEAD_MAX_YAW_MOVING = 60f;
|
||||||
|
|
||||||
|
/** Maximum head yaw relative to body while stationary (degrees) */
|
||||||
|
public static final float HEAD_MAX_YAW_STATIONARY = 90f;
|
||||||
|
|
||||||
|
/** Threshold ratio for detecting head-at-limit (triggers body snap) */
|
||||||
|
public static final float HEAD_AT_LIMIT_RATIO = 0.85f;
|
||||||
|
|
||||||
|
/** Ratio of max yaw to snap body to when releasing head */
|
||||||
|
public static final float HEAD_SNAP_RELEASE_RATIO = 0.7f;
|
||||||
|
|
||||||
|
// === Vertical offsets (model units, 16 = 1 block) ===
|
||||||
|
|
||||||
|
/** Y offset for DOG and PET BED poses (6/16 = 0.375 blocks) */
|
||||||
|
public static final double DOG_AND_PETBED_Y_OFFSET = -6.0 / 16.0;
|
||||||
|
|
||||||
|
/** Y offset for Damsel sitting pose (model units) */
|
||||||
|
public static final float DAMSEL_SIT_OFFSET = -10.0f;
|
||||||
|
|
||||||
|
/** Y offset for Damsel kneeling pose (model units) */
|
||||||
|
public static final float DAMSEL_KNEEL_OFFSET = -5.0f;
|
||||||
|
|
||||||
|
/** Y offset for Damsel dog pose (model units) */
|
||||||
|
public static final float DAMSEL_DOG_OFFSET = -7.0f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package com.tiedup.remake.client.animation.tick;
|
||||||
|
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import com.tiedup.remake.client.animation.AnimationStateRegistry;
|
||||||
|
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||||
|
import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||||
|
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||||
|
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||||
|
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||||
|
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||||
|
import com.tiedup.remake.client.events.CellHighlightHandler;
|
||||||
|
import com.tiedup.remake.client.events.LeashProxyClientHandler;
|
||||||
|
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||||
|
import com.tiedup.remake.client.state.ClothesClientCache;
|
||||||
|
import com.tiedup.remake.client.state.MovementStyleClientState;
|
||||||
|
import com.tiedup.remake.client.state.PetBedClientState;
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import com.tiedup.remake.state.HumanChairHelper;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||||
|
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||||
|
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for player animation tick updates.
|
||||||
|
*
|
||||||
|
* <p>Simplified handler that:
|
||||||
|
* <ul>
|
||||||
|
* <li>Tracks tied/struggling/sneaking state for players</li>
|
||||||
|
* <li>Plays animations via BondageAnimationManager when state changes</li>
|
||||||
|
* <li>Handles cleanup on logout/world unload</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Registered on the FORGE event bus (not MOD bus).
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = "tiedup",
|
||||||
|
value = Dist.CLIENT,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||||
|
)
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class AnimationTickHandler {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
/** Tick counter for periodic cleanup tasks */
|
||||||
|
private static int cleanupTickCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client tick event - called every tick on the client.
|
||||||
|
* Updates animations for all players when their bondage state changes.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.level == null || mc.isPaused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process pending animations first (retry failed animations for remote players)
|
||||||
|
PendingAnimationManager.processPending(mc.level);
|
||||||
|
|
||||||
|
// Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks)
|
||||||
|
if (++cleanupTickCounter >= 1200) {
|
||||||
|
cleanupTickCounter = 0;
|
||||||
|
ClothesClientCache.cleanupStale();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then update all player animations
|
||||||
|
for (Player player : mc.level.players()) {
|
||||||
|
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||||
|
updatePlayerAnimation(clientPlayer);
|
||||||
|
}
|
||||||
|
// Safety: remove stale furniture animations for players no longer on seats
|
||||||
|
BondageAnimationManager.tickFurnitureSafety(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update animation for a single player.
|
||||||
|
*/
|
||||||
|
private static void updatePlayerAnimation(AbstractClientPlayer player) {
|
||||||
|
// Safety check: skip for removed/dead players
|
||||||
|
if (player.isRemoved() || !player.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
UUID uuid = player.getUUID();
|
||||||
|
|
||||||
|
// Check if player has ANY V2 bondage item equipped (not just ARMS).
|
||||||
|
// isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
|
||||||
|
boolean isTied =
|
||||||
|
state != null &&
|
||||||
|
(state.isTiedUp() || V2EquipmentHelper.hasAnyEquipment(player));
|
||||||
|
boolean wasTied =
|
||||||
|
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
|
||||||
|
|
||||||
|
// Pet bed animations take priority over bondage animations
|
||||||
|
if (PetBedClientState.get(uuid) != 0) {
|
||||||
|
// Lock body rotation to bed facing (prevents camera from rotating the model)
|
||||||
|
float lockedRot = PetBedClientState.getFacing(uuid);
|
||||||
|
player.yBodyRot = lockedRot;
|
||||||
|
player.yBodyRotO = lockedRot;
|
||||||
|
|
||||||
|
// Clamp head rotation to ±50° from body (like vehicle)
|
||||||
|
float headRot = player.getYHeadRot();
|
||||||
|
float clamped =
|
||||||
|
lockedRot +
|
||||||
|
net.minecraft.util.Mth.clamp(
|
||||||
|
net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot),
|
||||||
|
-50f,
|
||||||
|
50f
|
||||||
|
);
|
||||||
|
player.setYHeadRot(clamped);
|
||||||
|
player.yHeadRotO = clamped;
|
||||||
|
|
||||||
|
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot)
|
||||||
|
// NO return — animation HUMAN_CHAIR must continue playing below
|
||||||
|
if (isTied && state != null) {
|
||||||
|
ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (HumanChairHelper.isActive(chairBind)) {
|
||||||
|
// 1st person only: clamp yRot so player can't look behind
|
||||||
|
// 3rd person: yRot untouched → camera orbits freely 360°
|
||||||
|
if (
|
||||||
|
player == Minecraft.getInstance().player &&
|
||||||
|
Minecraft.getInstance().options.getCameraType() ==
|
||||||
|
net.minecraft.client.CameraType.FIRST_PERSON
|
||||||
|
) {
|
||||||
|
float lockedRot = HumanChairHelper.getFacing(chairBind);
|
||||||
|
float camClamped =
|
||||||
|
lockedRot +
|
||||||
|
net.minecraft.util.Mth.clamp(
|
||||||
|
net.minecraft.util.Mth.wrapDegrees(
|
||||||
|
player.getYRot() - lockedRot
|
||||||
|
),
|
||||||
|
-90f,
|
||||||
|
90f
|
||||||
|
);
|
||||||
|
player.setYRot(camClamped);
|
||||||
|
player.yRotO =
|
||||||
|
lockedRot +
|
||||||
|
net.minecraft.util.Mth.clamp(
|
||||||
|
net.minecraft.util.Mth.wrapDegrees(
|
||||||
|
player.yRotO - lockedRot
|
||||||
|
),
|
||||||
|
-90f,
|
||||||
|
90f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTied) {
|
||||||
|
// Resolve V2 equipped items
|
||||||
|
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
|
||||||
|
player
|
||||||
|
);
|
||||||
|
Map<BodyRegionV2, ItemStack> equipped =
|
||||||
|
equipment != null ? equipment.getAllEquipped() : Map.of();
|
||||||
|
|
||||||
|
// Resolve ALL V2 items with GLB models and per-item bone ownership
|
||||||
|
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
|
||||||
|
RegionBoneMapper.resolveAllV2Items(equipped);
|
||||||
|
|
||||||
|
if (!v2Items.isEmpty()) {
|
||||||
|
// V2 path: multi-item composite animation
|
||||||
|
java.util.Set<String> allOwnedParts =
|
||||||
|
RegionBoneMapper.computeAllOwnedParts(v2Items);
|
||||||
|
MovementStyle activeStyle = MovementStyleClientState.get(
|
||||||
|
player.getUUID()
|
||||||
|
);
|
||||||
|
AnimationContext context = AnimationContextResolver.resolve(
|
||||||
|
player,
|
||||||
|
state,
|
||||||
|
activeStyle
|
||||||
|
);
|
||||||
|
GltfAnimationApplier.applyMultiItemV2Animation(
|
||||||
|
player,
|
||||||
|
v2Items,
|
||||||
|
context,
|
||||||
|
allOwnedParts
|
||||||
|
);
|
||||||
|
// Clear V1 tracking so transition back works
|
||||||
|
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||||
|
} else {
|
||||||
|
// V1 fallback
|
||||||
|
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||||
|
GltfAnimationApplier.clearV2Animation(player);
|
||||||
|
}
|
||||||
|
String animId = buildAnimationId(player, state);
|
||||||
|
String lastId = AnimationStateRegistry.getLastAnimId().get(
|
||||||
|
uuid
|
||||||
|
);
|
||||||
|
if (!animId.equals(lastId)) {
|
||||||
|
boolean success = BondageAnimationManager.playAnimation(
|
||||||
|
player,
|
||||||
|
animId
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
AnimationStateRegistry.getLastAnimId().put(
|
||||||
|
uuid,
|
||||||
|
animId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (wasTied) {
|
||||||
|
// Was tied, now free - stop all animations
|
||||||
|
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||||
|
GltfAnimationApplier.clearV2Animation(player);
|
||||||
|
} else {
|
||||||
|
BondageAnimationManager.stopAnimation(player);
|
||||||
|
}
|
||||||
|
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ID from player's current state (V1 path).
|
||||||
|
*/
|
||||||
|
private static String buildAnimationId(
|
||||||
|
Player player,
|
||||||
|
PlayerBindState state
|
||||||
|
) {
|
||||||
|
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
PoseType poseType = PoseType.STANDARD;
|
||||||
|
|
||||||
|
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||||
|
poseType = itemBind.getPoseType();
|
||||||
|
|
||||||
|
// Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs)
|
||||||
|
poseType = HumanChairHelper.resolveEffectivePose(poseType, bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive bound state from V2 regions (works client-side, synced via capability)
|
||||||
|
boolean armsBound = V2EquipmentHelper.isRegionOccupied(
|
||||||
|
player,
|
||||||
|
BodyRegionV2.ARMS
|
||||||
|
);
|
||||||
|
boolean legsBound = V2EquipmentHelper.isRegionOccupied(
|
||||||
|
player,
|
||||||
|
BodyRegionV2.LEGS
|
||||||
|
);
|
||||||
|
|
||||||
|
// V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT
|
||||||
|
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||||
|
armsBound = ItemBind.hasArmsBound(bind);
|
||||||
|
legsBound = ItemBind.hasLegsBound(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isStruggling = state.isStruggling();
|
||||||
|
boolean isSneaking = player.isCrouching();
|
||||||
|
boolean isMoving =
|
||||||
|
player.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||||
|
|
||||||
|
// Build animation ID with sneak and movement support
|
||||||
|
return AnimationIdBuilder.build(
|
||||||
|
poseType,
|
||||||
|
armsBound,
|
||||||
|
legsBound,
|
||||||
|
null,
|
||||||
|
isStruggling,
|
||||||
|
true,
|
||||||
|
isSneaking,
|
||||||
|
isMoving
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player logout event - cleanup animation data.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||||
|
if (event.getEntity().level().isClientSide()) {
|
||||||
|
UUID uuid = event.getEntity().getUUID();
|
||||||
|
AnimationStateRegistry.getLastTiedState().remove(uuid);
|
||||||
|
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||||
|
BondageAnimationManager.cleanup(uuid);
|
||||||
|
GltfAnimationApplier.removeTracking(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World unload event - clear all animation and cache data.
|
||||||
|
* FIX: Now also clears client-side caches to prevent memory leaks and stale data.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onWorldUnload(
|
||||||
|
net.minecraftforge.event.level.LevelEvent.Unload event
|
||||||
|
) {
|
||||||
|
if (event.getLevel().isClientSide()) {
|
||||||
|
// Animation state (includes BondageAnimationManager, PendingAnimationManager,
|
||||||
|
// DogPoseRenderHandler, MCAAnimationTickCache)
|
||||||
|
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
|
||||||
|
AnimationStateRegistry.clearAll();
|
||||||
|
|
||||||
|
// Non-animation client-side caches
|
||||||
|
PetBedClientState.clearAll();
|
||||||
|
MovementStyleClientState.clearAll();
|
||||||
|
com.tiedup.remake.client.state.CollarRegistryClient.clear();
|
||||||
|
CellHighlightHandler.clearCache();
|
||||||
|
LeashProxyClientHandler.clearAll();
|
||||||
|
com.tiedup.remake.client.state.ClientLaborState.clearTask();
|
||||||
|
com.tiedup.remake.client.state.ClothesClientCache.clearAll();
|
||||||
|
com.tiedup.remake.client.texture.DynamicTextureManager.getInstance().clearAll();
|
||||||
|
|
||||||
|
// C1: Player bind state client instances (prevents stale Player references)
|
||||||
|
PlayerBindState.clearClientInstances();
|
||||||
|
|
||||||
|
// C2: Armor stand bondage data (entity IDs are not stable across worlds)
|
||||||
|
com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache.clear();
|
||||||
|
|
||||||
|
// C3: Furniture GLB model cache (resource-backed, also cleared on F3+T reload)
|
||||||
|
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"Cleared all animation and cache data due to world unload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.tiedup.remake.client.animation.tick;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for MCA villager animation tick tracking.
|
||||||
|
* Used by MixinVillagerEntityBaseModelMCA to prevent animations from ticking
|
||||||
|
* multiple times per game tick.
|
||||||
|
*
|
||||||
|
* <p>This is extracted from the mixin so it can be cleared on world unload
|
||||||
|
* to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class MCAAnimationTickCache {
|
||||||
|
|
||||||
|
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
|
||||||
|
|
||||||
|
private MCAAnimationTickCache() {
|
||||||
|
// Utility class
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last tick value for an entity.
|
||||||
|
* @param uuid Entity UUID
|
||||||
|
* @return Last tick value, or -1 if not cached
|
||||||
|
*/
|
||||||
|
public static int getLastTick(UUID uuid) {
|
||||||
|
return lastTickMap.getOrDefault(uuid, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last tick value for an entity.
|
||||||
|
* @param uuid Entity UUID
|
||||||
|
* @param tick Current tick value
|
||||||
|
*/
|
||||||
|
public static void setLastTick(UUID uuid, int tick) {
|
||||||
|
lastTickMap.put(uuid, tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached data.
|
||||||
|
* Called on world unload to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
lastTickMap.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.tiedup.remake.client.animation.tick;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||||
|
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||||
|
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||||
|
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||||
|
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||||
|
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||||
|
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||||
|
import com.tiedup.remake.entities.EntityMaster;
|
||||||
|
import com.tiedup.remake.entities.ai.master.MasterState;
|
||||||
|
import com.tiedup.remake.items.base.ItemBind;
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||||
|
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tick handler for NPC (AbstractTiedUpNpc) bondage animations.
|
||||||
|
*
|
||||||
|
* <p>Same pattern as AnimationTickHandler for players, but for loaded
|
||||||
|
* AbstractTiedUpNpc instances. Tracks last animation ID per NPC UUID and
|
||||||
|
* triggers BondageAnimationManager.playAnimation() on state changes.
|
||||||
|
*
|
||||||
|
* <p>Extracted from DamselModel.setupAnim() to decouple animation
|
||||||
|
* triggering from model rendering.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = "tiedup",
|
||||||
|
value = Dist.CLIENT,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||||
|
)
|
||||||
|
public class NpcAnimationTickHandler {
|
||||||
|
|
||||||
|
/** Track last animation ID per NPC to avoid redundant updates */
|
||||||
|
private static final Map<UUID, String> lastNpcAnimId =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.level == null || mc.isPaused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Entity entity : mc.level.entitiesForRendering()) {
|
||||||
|
if (
|
||||||
|
entity instanceof AbstractTiedUpNpc damsel &&
|
||||||
|
entity.isAlive() &&
|
||||||
|
!entity.isRemoved()
|
||||||
|
) {
|
||||||
|
updateNpcAnimation(damsel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update animation for a single NPC.
|
||||||
|
*
|
||||||
|
* <p>Dual-layer V2 path: if the highest-priority equipped V2 item has a GLB model,
|
||||||
|
* uses {@link GltfAnimationApplier#applyV2Animation} which plays a context layer
|
||||||
|
* (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are
|
||||||
|
* handled by the context resolver, so the V2 path now covers all postures.
|
||||||
|
*
|
||||||
|
* <p>V1 fallback: if no V2 GLB model is found, falls back to JSON-based
|
||||||
|
* PlayerAnimator animations via {@link BondageAnimationManager}.
|
||||||
|
*/
|
||||||
|
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
|
||||||
|
boolean inPose =
|
||||||
|
entity.isTiedUp() || entity.isSitting() || entity.isKneeling();
|
||||||
|
|
||||||
|
UUID uuid = entity.getUUID();
|
||||||
|
|
||||||
|
if (inPose) {
|
||||||
|
// Resolve V2 equipment map
|
||||||
|
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
|
||||||
|
entity
|
||||||
|
);
|
||||||
|
Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped =
|
||||||
|
equipment != null ? equipment.getAllEquipped() : Map.of();
|
||||||
|
RegionBoneMapper.GlbModelResult glbResult =
|
||||||
|
RegionBoneMapper.resolveWinningItem(equipped);
|
||||||
|
|
||||||
|
if (glbResult != null) {
|
||||||
|
// V2 path: dual-layer animation with per-item bone ownership
|
||||||
|
RegionBoneMapper.BoneOwnership ownership =
|
||||||
|
RegionBoneMapper.computePerItemParts(
|
||||||
|
equipped,
|
||||||
|
glbResult.winningItem()
|
||||||
|
);
|
||||||
|
AnimationContext context = AnimationContextResolver.resolveNpc(
|
||||||
|
entity
|
||||||
|
);
|
||||||
|
GltfAnimationApplier.applyV2Animation(
|
||||||
|
entity,
|
||||||
|
glbResult.modelLoc(),
|
||||||
|
glbResult.animSource(),
|
||||||
|
context,
|
||||||
|
ownership
|
||||||
|
);
|
||||||
|
lastNpcAnimId.remove(uuid);
|
||||||
|
} else {
|
||||||
|
// V1 fallback: JSON-based PlayerAnimator animations
|
||||||
|
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||||
|
GltfAnimationApplier.clearV2Animation(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
String animId = buildNpcAnimationId(entity);
|
||||||
|
String lastId = lastNpcAnimId.get(uuid);
|
||||||
|
if (!animId.equals(lastId)) {
|
||||||
|
BondageAnimationManager.playAnimation(entity, animId);
|
||||||
|
lastNpcAnimId.put(uuid, animId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
lastNpcAnimId.containsKey(uuid) ||
|
||||||
|
GltfAnimationApplier.hasActiveState(entity)
|
||||||
|
) {
|
||||||
|
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||||
|
GltfAnimationApplier.clearV2Animation(entity);
|
||||||
|
} else {
|
||||||
|
BondageAnimationManager.stopAnimation(entity);
|
||||||
|
}
|
||||||
|
lastNpcAnimId.remove(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ID for an NPC from its current state (V1 path).
|
||||||
|
*/
|
||||||
|
private static String buildNpcAnimationId(AbstractTiedUpNpc entity) {
|
||||||
|
// Determine position prefix for SIT/KNEEL poses
|
||||||
|
String positionPrefix = null;
|
||||||
|
if (entity.isSitting()) {
|
||||||
|
positionPrefix = "sit";
|
||||||
|
} else if (entity.isKneeling()) {
|
||||||
|
positionPrefix = "kneel";
|
||||||
|
}
|
||||||
|
|
||||||
|
net.minecraft.world.item.ItemStack bind = entity.getEquipment(
|
||||||
|
BodyRegionV2.ARMS
|
||||||
|
);
|
||||||
|
PoseType poseType = PoseType.STANDARD;
|
||||||
|
boolean hasBind = false;
|
||||||
|
|
||||||
|
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||||
|
poseType = itemBind.getPoseType();
|
||||||
|
hasBind = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||||
|
boolean armsBound = V2EquipmentHelper.isRegionOccupied(
|
||||||
|
entity,
|
||||||
|
BodyRegionV2.ARMS
|
||||||
|
);
|
||||||
|
boolean legsBound = V2EquipmentHelper.isRegionOccupied(
|
||||||
|
entity,
|
||||||
|
BodyRegionV2.LEGS
|
||||||
|
);
|
||||||
|
|
||||||
|
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT
|
||||||
|
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||||
|
armsBound = ItemBind.hasArmsBound(bind);
|
||||||
|
legsBound = ItemBind.hasLegsBound(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isStruggling = entity.isStruggling();
|
||||||
|
boolean isSneaking = entity.isCrouching();
|
||||||
|
boolean isMoving =
|
||||||
|
entity.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||||
|
|
||||||
|
String animId = AnimationIdBuilder.build(
|
||||||
|
poseType,
|
||||||
|
armsBound,
|
||||||
|
legsBound,
|
||||||
|
positionPrefix,
|
||||||
|
isStruggling,
|
||||||
|
hasBind,
|
||||||
|
isSneaking,
|
||||||
|
isMoving
|
||||||
|
);
|
||||||
|
|
||||||
|
// Master NPC sitting on human chair: use dedicated sitting animation
|
||||||
|
if (
|
||||||
|
entity instanceof EntityMaster masterEntity &&
|
||||||
|
masterEntity.getMasterState() == MasterState.HUMAN_CHAIR &&
|
||||||
|
masterEntity.isSitting()
|
||||||
|
) {
|
||||||
|
animId = "master_chair_sit_idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
return animId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all NPC animation state.
|
||||||
|
* Called on world unload to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
lastNpcAnimId.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.tiedup.remake.client.animation.util;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.PoseType;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for building animation ResourceLocation IDs.
|
||||||
|
*
|
||||||
|
* <p>Centralizes the logic for constructing animation file names.
|
||||||
|
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
|
||||||
|
*
|
||||||
|
* <p>Animation naming convention:
|
||||||
|
* <pre>
|
||||||
|
* {poseType}_{bindMode}_{variant}.json
|
||||||
|
*
|
||||||
|
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
|
||||||
|
* bindMode: (empty for FULL) | _arms | _legs
|
||||||
|
* variant: _idle | _struggle | (empty for static)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Examples:
|
||||||
|
* <ul>
|
||||||
|
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
|
||||||
|
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
|
||||||
|
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class AnimationIdBuilder {
|
||||||
|
|
||||||
|
private static final String NAMESPACE = "tiedup";
|
||||||
|
|
||||||
|
// Bind mode suffixes
|
||||||
|
private static final String SUFFIX_ARMS = "_arms";
|
||||||
|
private static final String SUFFIX_LEGS = "_legs";
|
||||||
|
|
||||||
|
// Variant suffixes
|
||||||
|
private static final String SUFFIX_IDLE = "_idle";
|
||||||
|
private static final String SUFFIX_WALK = "_walk";
|
||||||
|
private static final String SUFFIX_STRUGGLE = "_struggle";
|
||||||
|
private static final String SUFFIX_SNEAK = "_sneak";
|
||||||
|
|
||||||
|
private AnimationIdBuilder() {
|
||||||
|
// Utility class - no instantiation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base animation name from pose type.
|
||||||
|
* Delegates to {@link PoseType#getAnimationId()}.
|
||||||
|
*
|
||||||
|
* @param poseType Pose type
|
||||||
|
* @return Base name string
|
||||||
|
*/
|
||||||
|
public static String getBaseName(PoseType poseType) {
|
||||||
|
return poseType.getAnimationId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get suffix for bind mode derived from region flags.
|
||||||
|
*
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
* @return Suffix string: "" for FULL (both), "_arms" for arms-only, "_legs" for legs-only
|
||||||
|
*/
|
||||||
|
public static String getModeSuffix(boolean armsBound, boolean legsBound) {
|
||||||
|
if (armsBound && legsBound) return ""; // FULL has no suffix
|
||||||
|
if (armsBound) return SUFFIX_ARMS;
|
||||||
|
if (legsBound) return SUFFIX_LEGS;
|
||||||
|
return ""; // neither bound = no suffix (shouldn't happen in practice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bind type name for SIT/KNEEL animations.
|
||||||
|
* Delegates to {@link PoseType#getBindTypeName()}.
|
||||||
|
*
|
||||||
|
* @param poseType Pose type
|
||||||
|
* @return Bind type name ("basic", "straitjacket", "wrap", "latex_sack")
|
||||||
|
*/
|
||||||
|
public static String getBindTypeName(PoseType poseType) {
|
||||||
|
return poseType.getBindTypeName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified Build Method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ID string for entities.
|
||||||
|
*
|
||||||
|
* <p>This method handles all cases:
|
||||||
|
* <ul>
|
||||||
|
* <li>Standing poses: tied_up_basic_idle, straitjacket_struggle, etc.</li>
|
||||||
|
* <li>Sitting poses: sit_basic_idle, sit_free_idle, etc.</li>
|
||||||
|
* <li>Kneeling poses: kneel_basic_idle, kneel_wrap_struggle, etc.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||||
|
* @param isStruggling Whether entity is struggling
|
||||||
|
* @param hasBind Whether entity has a bind equipped
|
||||||
|
* @return Animation ID string (without namespace)
|
||||||
|
*/
|
||||||
|
public static String build(
|
||||||
|
PoseType poseType,
|
||||||
|
boolean armsBound,
|
||||||
|
boolean legsBound,
|
||||||
|
String positionPrefix,
|
||||||
|
boolean isStruggling,
|
||||||
|
boolean hasBind
|
||||||
|
) {
|
||||||
|
return build(
|
||||||
|
poseType,
|
||||||
|
armsBound,
|
||||||
|
legsBound,
|
||||||
|
positionPrefix,
|
||||||
|
isStruggling,
|
||||||
|
hasBind,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ID string for entities with sneak support.
|
||||||
|
*
|
||||||
|
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||||
|
* @param isStruggling Whether entity is struggling
|
||||||
|
* @param hasBind Whether entity has a bind equipped
|
||||||
|
* @param isSneaking Whether entity is sneaking
|
||||||
|
* @return Animation ID string (without namespace)
|
||||||
|
*/
|
||||||
|
public static String build(
|
||||||
|
PoseType poseType,
|
||||||
|
boolean armsBound,
|
||||||
|
boolean legsBound,
|
||||||
|
String positionPrefix,
|
||||||
|
boolean isStruggling,
|
||||||
|
boolean hasBind,
|
||||||
|
boolean isSneaking
|
||||||
|
) {
|
||||||
|
return build(
|
||||||
|
poseType,
|
||||||
|
armsBound,
|
||||||
|
legsBound,
|
||||||
|
positionPrefix,
|
||||||
|
isStruggling,
|
||||||
|
hasBind,
|
||||||
|
isSneaking,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ID string for entities with sneak and movement support.
|
||||||
|
*
|
||||||
|
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||||
|
* @param isStruggling Whether entity is struggling
|
||||||
|
* @param hasBind Whether entity has a bind equipped
|
||||||
|
* @param isSneaking Whether entity is sneaking
|
||||||
|
* @param isMoving Whether entity is moving
|
||||||
|
* @return Animation ID string (without namespace)
|
||||||
|
*/
|
||||||
|
public static String build(
|
||||||
|
PoseType poseType,
|
||||||
|
boolean armsBound,
|
||||||
|
boolean legsBound,
|
||||||
|
String positionPrefix,
|
||||||
|
boolean isStruggling,
|
||||||
|
boolean hasBind,
|
||||||
|
boolean isSneaking,
|
||||||
|
boolean isMoving
|
||||||
|
) {
|
||||||
|
String sneakSuffix = isSneaking ? SUFFIX_SNEAK : "";
|
||||||
|
|
||||||
|
// Determine variant suffix based on state priority: struggle > walk > idle
|
||||||
|
String variantSuffix;
|
||||||
|
if (isStruggling) {
|
||||||
|
variantSuffix = SUFFIX_STRUGGLE;
|
||||||
|
} else if (isMoving && poseType == PoseType.DOG) {
|
||||||
|
// DOG pose has a walking animation (tied_up_dog_walk.json)
|
||||||
|
variantSuffix = SUFFIX_WALK;
|
||||||
|
} else {
|
||||||
|
variantSuffix = SUFFIX_IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIT or KNEEL pose
|
||||||
|
if (positionPrefix != null) {
|
||||||
|
if (!hasBind) {
|
||||||
|
// No bind: free pose (arms natural)
|
||||||
|
return positionPrefix + "_free" + sneakSuffix + variantSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has bind
|
||||||
|
String bindTypeName;
|
||||||
|
if (legsBound && !armsBound) {
|
||||||
|
// LEGS-only mode = arms free
|
||||||
|
bindTypeName = "legs";
|
||||||
|
} else {
|
||||||
|
// FULL or ARMS mode
|
||||||
|
bindTypeName = getBindTypeName(poseType);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
positionPrefix +
|
||||||
|
"_" +
|
||||||
|
bindTypeName +
|
||||||
|
sneakSuffix +
|
||||||
|
variantSuffix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing pose (no position prefix)
|
||||||
|
String baseName = getBaseName(poseType);
|
||||||
|
String modeSuffix = getModeSuffix(armsBound, legsBound);
|
||||||
|
|
||||||
|
// LEGS-only mode: only lock legs, arms are free - no idle/struggle variants needed
|
||||||
|
if (legsBound && !armsBound) {
|
||||||
|
return baseName + modeSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseName + modeSuffix + sneakSuffix + variantSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ResourceLocation for SIT or KNEEL pose.
|
||||||
|
*
|
||||||
|
* @param posePrefix "sit" or "kneel"
|
||||||
|
* @param poseType Bind pose type
|
||||||
|
* @param armsBound whether ARMS region is occupied
|
||||||
|
* @param legsBound whether LEGS region is occupied
|
||||||
|
* @param isStruggling Whether entity is struggling
|
||||||
|
* @return Animation ResourceLocation
|
||||||
|
*/
|
||||||
|
public static ResourceLocation buildPositionAnimation(
|
||||||
|
String posePrefix,
|
||||||
|
PoseType poseType,
|
||||||
|
boolean armsBound,
|
||||||
|
boolean legsBound,
|
||||||
|
boolean isStruggling
|
||||||
|
) {
|
||||||
|
String bindTypeName;
|
||||||
|
if (legsBound && !armsBound) {
|
||||||
|
bindTypeName = "legs";
|
||||||
|
} else {
|
||||||
|
bindTypeName = getBindTypeName(poseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String variantSuffix = isStruggling ? SUFFIX_STRUGGLE : SUFFIX_IDLE;
|
||||||
|
String animationName = posePrefix + "_" + bindTypeName + variantSuffix;
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build animation ResourceLocation for SIT or KNEEL pose when NOT bound.
|
||||||
|
*
|
||||||
|
* @param posePrefix "sit" or "kneel"
|
||||||
|
* @return Animation ResourceLocation for free pose
|
||||||
|
*/
|
||||||
|
public static ResourceLocation buildFreePositionAnimation(
|
||||||
|
String posePrefix
|
||||||
|
) {
|
||||||
|
String animationName = posePrefix + "_free" + SUFFIX_IDLE;
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.tiedup.remake.client.animation.util;
|
||||||
|
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for DOG pose head compensation.
|
||||||
|
*
|
||||||
|
* <h2>Problem</h2>
|
||||||
|
* <p>When in DOG pose, the body is rotated -90° pitch (horizontal, face down).
|
||||||
|
* This makes the head point at the ground. We need to compensate:
|
||||||
|
* <ul>
|
||||||
|
* <li>Head pitch: add -90° offset so head looks forward</li>
|
||||||
|
* <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Architecture: Players vs NPCs</h2>
|
||||||
|
* <pre>
|
||||||
|
* ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ PLAYERS │
|
||||||
|
* ├─────────────────────────────────────────────────────────────────┤
|
||||||
|
* │ 1. PlayerArmHideEventHandler.onRenderPlayerPre() │
|
||||||
|
* │ - Offset vertical (-6 model units) │
|
||||||
|
* │ - Rotation Y lissée (dogPoseState tracking) │
|
||||||
|
* │ │
|
||||||
|
* │ 2. Animation (PlayerAnimator) │
|
||||||
|
* │ - body.pitch = -90° → appliqué au PoseStack automatiquement │
|
||||||
|
* │ │
|
||||||
|
* │ 3. MixinPlayerModel.setupAnim() @TAIL │
|
||||||
|
* │ - Uses DogPoseHelper.applyHeadCompensationClamped() │
|
||||||
|
* └─────────────────────────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ NPCs │
|
||||||
|
* ├─────────────────────────────────────────────────────────────────┤
|
||||||
|
* │ 1. EntityDamsel.tick() │
|
||||||
|
* │ - Uses RotationSmoother for Y rotation (10% per tick) │
|
||||||
|
* │ │
|
||||||
|
* │ 2. DamselRenderer.setupRotations() │
|
||||||
|
* │ - super.setupRotations() (applique rotation Y) │
|
||||||
|
* │ - Rotation X -90° au PoseStack (APRÈS Y = espace local) │
|
||||||
|
* │ - Offset vertical (-7 model units) │
|
||||||
|
* │ │
|
||||||
|
* │ 3. DamselModel.setupAnim() │
|
||||||
|
* │ - body.xRot = 0 (évite double rotation) │
|
||||||
|
* │ - Uses DogPoseHelper.applyHeadCompensation() │
|
||||||
|
* └─────────────────────────────────────────────────────────────────┘
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h2>Key Differences</h2>
|
||||||
|
* <table>
|
||||||
|
* <tr><th>Aspect</th><th>Players</th><th>NPCs</th></tr>
|
||||||
|
* <tr><td>Rotation X application</td><td>Auto by PlayerAnimator</td><td>Manual in setupRotations()</td></tr>
|
||||||
|
* <tr><td>Rotation Y smoothing</td><td>PlayerArmHideEventHandler</td><td>EntityDamsel.tick() via RotationSmoother</td></tr>
|
||||||
|
* <tr><td>Head compensation</td><td>MixinPlayerModel</td><td>DamselModel.setupAnim()</td></tr>
|
||||||
|
* <tr><td>Reset body.xRot</td><td>Not needed</td><td>Yes (prevents double rotation)</td></tr>
|
||||||
|
* <tr><td>Vertical offset</td><td>-6 model units</td><td>-7 model units</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* <h2>Usage</h2>
|
||||||
|
* <p>Used by:
|
||||||
|
* <ul>
|
||||||
|
* <li>MixinPlayerModel - for player head compensation</li>
|
||||||
|
* <li>DamselModel - for NPC head compensation</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @see RotationSmoother for Y rotation smoothing
|
||||||
|
* @see com.tiedup.remake.mixin.client.MixinPlayerModel
|
||||||
|
* @see com.tiedup.remake.client.model.DamselModel
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class DogPoseHelper {
|
||||||
|
|
||||||
|
private static final float DEG_TO_RAD = (float) Math.PI / 180F;
|
||||||
|
private static final float HEAD_PITCH_OFFSET = (float) Math.toRadians(-90);
|
||||||
|
|
||||||
|
private DogPoseHelper() {
|
||||||
|
// Utility class - no instantiation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply head compensation for DOG pose (horizontal body).
|
||||||
|
*
|
||||||
|
* <p>When body is horizontal (-90° pitch), the head needs compensation:
|
||||||
|
* <ul>
|
||||||
|
* <li>xRot: -90° offset + player's up/down look (headPitch)</li>
|
||||||
|
* <li>yRot: 0 (this axis points sideways when body is horizontal)</li>
|
||||||
|
* <li>zRot: -headYaw (left/right look, replaces yaw)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param head The head ModelPart to modify
|
||||||
|
* @param hat The hat ModelPart to sync (can be null)
|
||||||
|
* @param headPitch Player's up/down look angle in degrees
|
||||||
|
* @param headYaw Head yaw relative to body in degrees (netHeadYaw for NPCs,
|
||||||
|
* netHeadYaw + rotationDelta for players)
|
||||||
|
*/
|
||||||
|
public static void applyHeadCompensation(
|
||||||
|
ModelPart head,
|
||||||
|
ModelPart hat,
|
||||||
|
float headPitch,
|
||||||
|
float headYaw
|
||||||
|
) {
|
||||||
|
float pitchRad = headPitch * DEG_TO_RAD;
|
||||||
|
float yawRad = headYaw * DEG_TO_RAD;
|
||||||
|
|
||||||
|
// xRot: base offset (-90° to look forward) + player's up/down look
|
||||||
|
head.xRot = HEAD_PITCH_OFFSET + pitchRad;
|
||||||
|
|
||||||
|
// yRot: stays at 0 (this axis points sideways when body is horizontal)
|
||||||
|
head.yRot = 0;
|
||||||
|
|
||||||
|
// zRot: used for left/right look (replaces yaw since body is horizontal)
|
||||||
|
head.zRot = -yawRad;
|
||||||
|
|
||||||
|
// Sync hat layer if provided
|
||||||
|
if (hat != null) {
|
||||||
|
hat.copyFrom(head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply head compensation with yaw clamping.
|
||||||
|
*
|
||||||
|
* <p>Same as {@link #applyHeadCompensation} but clamps yaw to a maximum angle.
|
||||||
|
* Used for players where yaw range depends on movement state.
|
||||||
|
*
|
||||||
|
* @param head The head ModelPart to modify
|
||||||
|
* @param hat The hat ModelPart to sync (can be null)
|
||||||
|
* @param headPitch Player's up/down look angle in degrees
|
||||||
|
* @param headYaw Head yaw relative to body in degrees
|
||||||
|
* @param maxYaw Maximum allowed yaw angle in degrees
|
||||||
|
*/
|
||||||
|
public static void applyHeadCompensationClamped(
|
||||||
|
ModelPart head,
|
||||||
|
ModelPart hat,
|
||||||
|
float headPitch,
|
||||||
|
float headYaw,
|
||||||
|
float maxYaw
|
||||||
|
) {
|
||||||
|
// Wrap first so 350° becomes -10° before clamping (fixes full-rotation accumulation)
|
||||||
|
float clampedYaw = net.minecraft.util.Mth.clamp(
|
||||||
|
net.minecraft.util.Mth.wrapDegrees(headYaw),
|
||||||
|
-maxYaw,
|
||||||
|
maxYaw
|
||||||
|
);
|
||||||
|
applyHeadCompensation(head, hat, headPitch, clampedYaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.systems.RenderSystem;
|
||||||
|
import com.tiedup.remake.core.ModConfig;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||||
|
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius.
|
||||||
|
*
|
||||||
|
* The original approach:
|
||||||
|
* 1. Render blindfold texture over the entire screen (covers everything)
|
||||||
|
* 2. Manually redraw the hotbar on top of the blindfold
|
||||||
|
*
|
||||||
|
* This ensures the hotbar remains visible while the game world is obscured.
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class BlindfoldRenderEventHandler {
|
||||||
|
|
||||||
|
private static final ResourceLocation BLINDFOLD_TEXTURE =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
TiedUpMod.MOD_ID,
|
||||||
|
"textures/misc/blindfolded.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vanilla widgets texture (contains hotbar graphics)
|
||||||
|
private static final ResourceLocation WIDGETS_TEXTURE =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"minecraft",
|
||||||
|
"textures/gui/widgets.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static boolean wasBlindfolded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the blindfold overlay AFTER the hotbar is rendered.
|
||||||
|
* Then redraw the hotbar on top of the blindfold (original mod approach).
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderGuiPost(RenderGuiOverlayEvent.Post event) {
|
||||||
|
// Render after HOTBAR (same as original mod)
|
||||||
|
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
LocalPlayer player = mc.player;
|
||||||
|
|
||||||
|
// Safety checks
|
||||||
|
if (player == null || mc.options.hideGui) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-hardcore mode: hide blindfold when a GUI screen is open
|
||||||
|
boolean hardcore = ModConfig.CLIENT.hardcoreBlindfold.get();
|
||||||
|
if (!hardcore && mc.screen != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get player state
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isBlindfolded = state.isBlindfolded();
|
||||||
|
|
||||||
|
// Log state changes only
|
||||||
|
if (isBlindfolded != wasBlindfolded) {
|
||||||
|
if (isBlindfolded) {
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BLINDFOLD] Player is now blindfolded - rendering overlay"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
TiedUpMod.LOGGER.info(
|
||||||
|
"[BLINDFOLD] Player is no longer blindfolded - stopping overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
wasBlindfolded = isBlindfolded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render if blindfolded
|
||||||
|
if (!isBlindfolded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||||
|
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||||
|
|
||||||
|
// Set opacity: hardcore forces full opacity, otherwise use config
|
||||||
|
float opacity = hardcore
|
||||||
|
? 1.0F
|
||||||
|
: ModConfig.CLIENT.blindfoldOverlayOpacity.get().floatValue();
|
||||||
|
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity);
|
||||||
|
RenderSystem.enableBlend();
|
||||||
|
RenderSystem.defaultBlendFunc();
|
||||||
|
|
||||||
|
// Step 1: Render the blindfold texture over the entire screen
|
||||||
|
event
|
||||||
|
.getGuiGraphics()
|
||||||
|
.blit(
|
||||||
|
BLINDFOLD_TEXTURE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0F,
|
||||||
|
0.0F,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset shader color for hotbar
|
||||||
|
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||||
|
|
||||||
|
// Step 2: Redraw the hotbar on top of the blindfold (original mod approach)
|
||||||
|
redrawHotbar(
|
||||||
|
mc,
|
||||||
|
event.getGuiGraphics(),
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
player
|
||||||
|
);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
TiedUpMod.LOGGER.error("[BLINDFOLD] Error rendering overlay", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually redraw the hotbar on top of the blindfold texture.
|
||||||
|
* Based on the original mod's redrawHotBar() function.
|
||||||
|
*
|
||||||
|
* This draws:
|
||||||
|
* - Hotbar background (182x22 pixels)
|
||||||
|
* - Selected slot highlight
|
||||||
|
* - Offhand slot (if item present)
|
||||||
|
*/
|
||||||
|
private static void redrawHotbar(
|
||||||
|
Minecraft mc,
|
||||||
|
GuiGraphics guiGraphics,
|
||||||
|
int screenWidth,
|
||||||
|
int screenHeight,
|
||||||
|
LocalPlayer player
|
||||||
|
) {
|
||||||
|
// Reset render state
|
||||||
|
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||||
|
RenderSystem.enableBlend();
|
||||||
|
RenderSystem.defaultBlendFunc();
|
||||||
|
|
||||||
|
// Center of screen (hotbar is centered)
|
||||||
|
int centerX = screenWidth / 2;
|
||||||
|
int hotbarY = screenHeight - 22; // Hotbar is 22 pixels from bottom
|
||||||
|
|
||||||
|
// Draw hotbar background (182 pixels wide, 22 pixels tall)
|
||||||
|
// Original: this.drawTexturedModalRect(i - 91, sr.getScaledHeight() - 22, 0, 0, 182, 22);
|
||||||
|
guiGraphics.blit(
|
||||||
|
WIDGETS_TEXTURE,
|
||||||
|
centerX - 91,
|
||||||
|
hotbarY, // Position
|
||||||
|
0,
|
||||||
|
0, // Texture UV start
|
||||||
|
182,
|
||||||
|
22 // Size
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw selected slot highlight (24x22 pixels)
|
||||||
|
// Original: this.drawTexturedModalRect(i - 91 - 1 + entityplayer.inventory.currentItem * 20, ...);
|
||||||
|
int selectedSlot = player.getInventory().selected;
|
||||||
|
guiGraphics.blit(
|
||||||
|
WIDGETS_TEXTURE,
|
||||||
|
centerX - 91 - 1 + selectedSlot * 20,
|
||||||
|
hotbarY - 1, // Position (offset by selected slot)
|
||||||
|
0,
|
||||||
|
22, // Texture UV (highlight texture)
|
||||||
|
24,
|
||||||
|
22 // Size
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw offhand slot if player has an item in offhand
|
||||||
|
ItemStack offhandItem = player.getItemInHand(InteractionHand.OFF_HAND);
|
||||||
|
if (!offhandItem.isEmpty()) {
|
||||||
|
HumanoidArm offhandSide = player.getMainArm().getOpposite();
|
||||||
|
|
||||||
|
if (offhandSide == HumanoidArm.LEFT) {
|
||||||
|
// Offhand on left side
|
||||||
|
// Original: this.drawTexturedModalRect(i - 91 - 29, sr.getScaledHeight() - 23, 24, 22, 29, 24);
|
||||||
|
guiGraphics.blit(
|
||||||
|
WIDGETS_TEXTURE,
|
||||||
|
centerX - 91 - 29,
|
||||||
|
hotbarY - 1, // Position
|
||||||
|
24,
|
||||||
|
22, // Texture UV
|
||||||
|
29,
|
||||||
|
24 // Size
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Offhand on right side
|
||||||
|
// Original: this.drawTexturedModalRect(i + 91, sr.getScaledHeight() - 23, 53, 22, 29, 24);
|
||||||
|
guiGraphics.blit(
|
||||||
|
WIDGETS_TEXTURE,
|
||||||
|
centerX + 91,
|
||||||
|
hotbarY - 1, // Position
|
||||||
|
53,
|
||||||
|
22, // Texture UV
|
||||||
|
29,
|
||||||
|
24 // Size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderSystem.disableBlend();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.tiedup.remake.blocks.BlockMarker;
|
||||||
|
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
|
||||||
|
import com.tiedup.remake.cells.CellDataV2;
|
||||||
|
import com.tiedup.remake.cells.CellRegistryV2;
|
||||||
|
import com.tiedup.remake.cells.MarkerType;
|
||||||
|
import com.tiedup.remake.client.renderer.CellOutlineRenderer;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.ItemAdminWand;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.minecraft.client.Camera;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.client.event.RenderLevelStageEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for rendering cell outlines when holding an admin wand.
|
||||||
|
*
|
||||||
|
* Phase: Kidnapper Revamp - Cell System
|
||||||
|
*
|
||||||
|
* Renders colored outlines around cell positions when:
|
||||||
|
* - Player is holding an Admin Wand
|
||||||
|
* - A cell is currently selected in the wand
|
||||||
|
*
|
||||||
|
* The outlines help builders visualize which blocks are part of the cell.
|
||||||
|
*
|
||||||
|
* Network sync: On dedicated servers, cell data is synced via PacketSyncCellData.
|
||||||
|
* On integrated servers, direct access to server data is used as a fallback.
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class CellHighlightHandler {
|
||||||
|
|
||||||
|
// Client-side cache of cell data (synced from server via PacketSyncCellData)
|
||||||
|
private static final java.util.Map<UUID, CellDataV2> syncedCells =
|
||||||
|
new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Legacy single-cell cache for backward compatibility
|
||||||
|
private static CellDataV2 cachedCellData = null;
|
||||||
|
private static UUID cachedCellId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render cell outlines after translucent blocks.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderLevelStage(RenderLevelStageEvent event) {
|
||||||
|
// Only render after translucent stage (so outlines appear on top)
|
||||||
|
if (
|
||||||
|
event.getStage() !=
|
||||||
|
RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
LocalPlayer player = mc.player;
|
||||||
|
|
||||||
|
if (player == null) return;
|
||||||
|
|
||||||
|
// Check if player is holding an Admin Wand
|
||||||
|
ItemStack mainHand = player.getMainHandItem();
|
||||||
|
ItemStack offHand = player.getOffhandItem();
|
||||||
|
|
||||||
|
boolean holdingAdminWand =
|
||||||
|
mainHand.getItem() instanceof ItemAdminWand ||
|
||||||
|
offHand.getItem() instanceof ItemAdminWand;
|
||||||
|
|
||||||
|
if (!holdingAdminWand) {
|
||||||
|
cachedCellData = null;
|
||||||
|
cachedCellId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PoseStack poseStack = event.getPoseStack();
|
||||||
|
Camera camera = event.getCamera();
|
||||||
|
|
||||||
|
// If holding Admin Wand, render nearby structure markers and preview
|
||||||
|
renderNearbyStructureMarkers(poseStack, camera, player);
|
||||||
|
renderAdminWandPreview(poseStack, camera, player, mainHand, offHand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a preview outline showing where the Admin Wand will place a marker.
|
||||||
|
*/
|
||||||
|
private static void renderAdminWandPreview(
|
||||||
|
PoseStack poseStack,
|
||||||
|
Camera camera,
|
||||||
|
LocalPlayer player,
|
||||||
|
ItemStack mainHand,
|
||||||
|
ItemStack offHand
|
||||||
|
) {
|
||||||
|
// Get the block the player is looking at
|
||||||
|
net.minecraft.world.phys.HitResult hitResult =
|
||||||
|
Minecraft.getInstance().hitResult;
|
||||||
|
if (
|
||||||
|
hitResult == null ||
|
||||||
|
hitResult.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
net.minecraft.world.phys.BlockHitResult blockHit =
|
||||||
|
(net.minecraft.world.phys.BlockHitResult) hitResult;
|
||||||
|
BlockPos targetPos = blockHit.getBlockPos().above(); // Marker goes above the clicked block
|
||||||
|
|
||||||
|
// Get the current marker type from the wand
|
||||||
|
MarkerType type;
|
||||||
|
if (mainHand.getItem() instanceof ItemAdminWand) {
|
||||||
|
type = ItemAdminWand.getCurrentType(mainHand);
|
||||||
|
} else {
|
||||||
|
type = ItemAdminWand.getCurrentType(offHand);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3 cameraPos = camera.getPosition();
|
||||||
|
float[] color = CellOutlineRenderer.getColorForType(type);
|
||||||
|
|
||||||
|
// Make preview semi-transparent and pulsing
|
||||||
|
float alpha =
|
||||||
|
0.5f + 0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0);
|
||||||
|
float[] previewColor = { color[0], color[1], color[2], alpha };
|
||||||
|
|
||||||
|
// Setup rendering (depth test off so preview shows through blocks)
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.setShader(
|
||||||
|
net.minecraft.client.renderer.GameRenderer::getPositionColorShader
|
||||||
|
);
|
||||||
|
|
||||||
|
CellOutlineRenderer.renderFilledBlock(
|
||||||
|
poseStack,
|
||||||
|
targetPos,
|
||||||
|
cameraPos,
|
||||||
|
previewColor
|
||||||
|
);
|
||||||
|
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render outlines for nearby structure markers (markers without cell IDs).
|
||||||
|
*/
|
||||||
|
private static void renderNearbyStructureMarkers(
|
||||||
|
PoseStack poseStack,
|
||||||
|
Camera camera,
|
||||||
|
LocalPlayer player
|
||||||
|
) {
|
||||||
|
Level level = player.level();
|
||||||
|
BlockPos playerPos = player.blockPosition();
|
||||||
|
Vec3 cameraPos = camera.getPosition();
|
||||||
|
|
||||||
|
// Collect markers first to check if we need to render anything
|
||||||
|
java.util.List<
|
||||||
|
java.util.Map.Entry<BlockPos, MarkerType>
|
||||||
|
> markersToRender = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
// Scan in a 32-block radius for structure markers
|
||||||
|
int radius = 32;
|
||||||
|
|
||||||
|
for (int x = -radius; x <= radius; x++) {
|
||||||
|
for (int y = -radius / 2; y <= radius / 2; y++) {
|
||||||
|
for (int z = -radius; z <= radius; z++) {
|
||||||
|
BlockPos pos = playerPos.offset(x, y, z);
|
||||||
|
|
||||||
|
if (
|
||||||
|
level.getBlockState(pos).getBlock() instanceof
|
||||||
|
BlockMarker
|
||||||
|
) {
|
||||||
|
BlockEntity be = level.getBlockEntity(pos);
|
||||||
|
if (be instanceof MarkerBlockEntity marker) {
|
||||||
|
// Only render structure markers (no cell ID)
|
||||||
|
if (marker.getCellId() == null) {
|
||||||
|
markersToRender.add(
|
||||||
|
java.util.Map.entry(
|
||||||
|
pos,
|
||||||
|
marker.getMarkerType()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only setup rendering if we have markers to render
|
||||||
|
if (!markersToRender.isEmpty()) {
|
||||||
|
// Setup rendering state (depth test off so markers show through blocks)
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.setShader(
|
||||||
|
net.minecraft.client.renderer
|
||||||
|
.GameRenderer::getPositionColorShader
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var entry : markersToRender) {
|
||||||
|
BlockPos pos = entry.getKey();
|
||||||
|
MarkerType type = entry.getValue();
|
||||||
|
float[] baseColor = CellOutlineRenderer.getColorForType(type);
|
||||||
|
// Semi-transparent fill
|
||||||
|
float[] fillColor = {
|
||||||
|
baseColor[0],
|
||||||
|
baseColor[1],
|
||||||
|
baseColor[2],
|
||||||
|
0.4f,
|
||||||
|
};
|
||||||
|
CellOutlineRenderer.renderFilledBlock(
|
||||||
|
poseStack,
|
||||||
|
pos,
|
||||||
|
cameraPos,
|
||||||
|
fillColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore rendering state
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
|
||||||
|
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cell data on client side.
|
||||||
|
* First checks the network-synced cache, then falls back to integrated server access.
|
||||||
|
*
|
||||||
|
* @param cellId The cell UUID to look up
|
||||||
|
* @return CellDataV2 if found, null otherwise
|
||||||
|
*/
|
||||||
|
private static CellDataV2 getCellDataClient(UUID cellId) {
|
||||||
|
if (cellId == null) return null;
|
||||||
|
|
||||||
|
// Priority 1: Check network-synced cache (works on dedicated servers)
|
||||||
|
CellDataV2 synced = syncedCells.get(cellId);
|
||||||
|
if (synced != null) {
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check legacy single-cell cache
|
||||||
|
if (cellId.equals(cachedCellId) && cachedCellData != null) {
|
||||||
|
return cachedCellData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: On integrated server, access server level directly (fallback)
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.getSingleplayerServer() != null) {
|
||||||
|
ServerLevel serverLevel = mc.getSingleplayerServer().overworld();
|
||||||
|
if (serverLevel != null) {
|
||||||
|
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||||
|
CellDataV2 cell = registry.getCell(cellId);
|
||||||
|
if (cell != null) {
|
||||||
|
// Cache for future use
|
||||||
|
cachedCellId = cellId;
|
||||||
|
cachedCellData = cell;
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found - on dedicated server, packet hasn't arrived yet
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cached cell data (called from network sync - PacketSyncCellData).
|
||||||
|
* Stores in both the synced map and legacy cache for compatibility.
|
||||||
|
*
|
||||||
|
* @param cell The cell data received from server
|
||||||
|
*/
|
||||||
|
public static void updateCachedCell(CellDataV2 cell) {
|
||||||
|
if (cell != null) {
|
||||||
|
// Store in synced map
|
||||||
|
syncedCells.put(cell.getId(), cell);
|
||||||
|
|
||||||
|
// Also update legacy cache
|
||||||
|
cachedCellId = cell.getId();
|
||||||
|
cachedCellData = cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a cell from the cache (e.g., when cell is deleted).
|
||||||
|
*
|
||||||
|
* @param cellId The cell UUID to remove
|
||||||
|
*/
|
||||||
|
public static void removeCachedCell(UUID cellId) {
|
||||||
|
if (cellId != null) {
|
||||||
|
syncedCells.remove(cellId);
|
||||||
|
if (cellId.equals(cachedCellId)) {
|
||||||
|
cachedCellId = null;
|
||||||
|
cachedCellData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached cell data.
|
||||||
|
* Called when disconnecting from server or on dimension change.
|
||||||
|
*/
|
||||||
|
public static void clearCache() {
|
||||||
|
syncedCells.clear();
|
||||||
|
cachedCellId = null;
|
||||||
|
cachedCellData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.MuffledSoundInstance;
|
||||||
|
import com.tiedup.remake.core.ModConfig;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.client.resources.sounds.SoundInstance;
|
||||||
|
import net.minecraft.sounds.SoundSource;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.sound.PlaySoundEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side sound handler for earplugs effect.
|
||||||
|
*
|
||||||
|
* When the player has earplugs equipped, all sounds are muffled
|
||||||
|
* (volume reduced to simulate hearing impairment).
|
||||||
|
*
|
||||||
|
* Based on Forge's PlaySoundEvent to intercept and modify sounds.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class EarplugSoundHandler {
|
||||||
|
|
||||||
|
/** Pitch modifier to make sounds more muffled (much lower for "underwater" effect) */
|
||||||
|
private static final float MUFFLED_PITCH_MODIFIER = 0.6f;
|
||||||
|
|
||||||
|
/** Categories to always let through at normal volume (important UI feedback) */
|
||||||
|
private static final SoundSource[] UNAFFECTED_CATEGORIES = {
|
||||||
|
SoundSource.MASTER, // Master volume controls
|
||||||
|
SoundSource.MUSIC, // Music is internal, not muffled by earplugs
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept sound events and muffle them if player has earplugs.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onPlaySound(PlaySoundEvent event) {
|
||||||
|
// Get the sound being played
|
||||||
|
SoundInstance sound = event.getSound();
|
||||||
|
if (sound == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if player has earplugs
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocalPlayer player = mc.player;
|
||||||
|
if (player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null || !state.hasEarplugs()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this sound category should be affected
|
||||||
|
SoundSource source = sound.getSource();
|
||||||
|
for (SoundSource unaffected : UNAFFECTED_CATEGORIES) {
|
||||||
|
if (source == unaffected) {
|
||||||
|
return; // Don't muffle this category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't wrap already-wrapped sounds (prevent infinite recursion)
|
||||||
|
if (sound instanceof MuffledSoundInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the sound with our muffling wrapper
|
||||||
|
// The wrapper delegates to the original but modifies getVolume()/getPitch()
|
||||||
|
SoundInstance muffledSound = new MuffledSoundInstance(
|
||||||
|
sound,
|
||||||
|
ModConfig.CLIENT.earplugVolumeMultiplier.get().floatValue(),
|
||||||
|
MUFFLED_PITCH_MODIFIER
|
||||||
|
);
|
||||||
|
|
||||||
|
event.setSound(muffledSound);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.mojang.logging.LogUtils;
|
||||||
|
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||||
|
import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic cleanup handler for entity-related resources.
|
||||||
|
*
|
||||||
|
* <p>This handler automatically cleans up animation layers and pending animations
|
||||||
|
* when entities leave the world, preventing memory leaks from stale cache entries.
|
||||||
|
*
|
||||||
|
* <p>Phase: Performance & Memory Management
|
||||||
|
*
|
||||||
|
* <p>Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)},
|
||||||
|
* which was error-prone and could lead to memory leaks if forgotten.
|
||||||
|
* This handler ensures cleanup happens automatically on entity removal.
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public class EntityCleanupHandler {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogUtils.getLogger();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically clean up animation resources when an entity leaves the world.
|
||||||
|
*
|
||||||
|
* <p>This event fires when:
|
||||||
|
* <ul>
|
||||||
|
* <li>An entity is removed from the world (killed, despawned, unloaded)</li>
|
||||||
|
* <li>A player logs out</li>
|
||||||
|
* <li>A chunk is unloaded and its entities are removed</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Cleanup includes:
|
||||||
|
* <ul>
|
||||||
|
* <li>Removing animation layers from {@link BondageAnimationManager}</li>
|
||||||
|
* <li>Removing pending animations from {@link PendingAnimationManager}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param event The entity leave level event
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onEntityLeaveLevel(EntityLeaveLevelEvent event) {
|
||||||
|
// Only process on client side
|
||||||
|
if (!event.getLevel().isClientSide()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up animation layers
|
||||||
|
BondageAnimationManager.cleanup(event.getEntity().getUUID());
|
||||||
|
|
||||||
|
// Clean up pending animation queue
|
||||||
|
PendingAnimationManager.remove(event.getEntity().getUUID());
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"Auto-cleaned animation resources for entity: {} (type: {})",
|
||||||
|
event.getEntity().getUUID(),
|
||||||
|
event.getEntity().getClass().getSimpleName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.items.ModItems;
|
||||||
|
import com.tiedup.remake.items.base.BindVariant;
|
||||||
|
import com.tiedup.remake.state.IBondageState;
|
||||||
|
import com.tiedup.remake.util.KidnappedHelper;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side handler for smooth leash proxy positioning.
|
||||||
|
*
|
||||||
|
* FIX: Changed from RenderLevelStageEvent.AFTER_ENTITIES to ClientTickEvent.
|
||||||
|
* AFTER_ENTITIES positioned the proxy AFTER rendering, causing 1-frame lag.
|
||||||
|
* ClientTickEvent positions BEFORE rendering for smooth leash display.
|
||||||
|
*
|
||||||
|
* Instead of waiting for server position updates (which causes lag),
|
||||||
|
* this handler repositions the proxy entity locally each tick based
|
||||||
|
* on the player's current position.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = TiedUpMod.MOD_ID,
|
||||||
|
value = Dist.CLIENT,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||||
|
)
|
||||||
|
public class LeashProxyClientHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of player UUID -> proxy entity ID.
|
||||||
|
* Uses UUID for player (persistent) and entity ID for proxy (runtime).
|
||||||
|
*/
|
||||||
|
private static final Map<UUID, Integer> playerToProxy =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Default Y offset for normal standing pose (neck height) */
|
||||||
|
private static final double DEFAULT_Y_OFFSET = 1.3;
|
||||||
|
|
||||||
|
/** Y offset for dogwalk pose (back/hip level) */
|
||||||
|
private static final double DOGWALK_Y_OFFSET = 0.35;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle sync packet from server.
|
||||||
|
* Called when a player gets leashed or unleashed.
|
||||||
|
*/
|
||||||
|
public static void handleSyncPacket(
|
||||||
|
UUID targetPlayerUUID,
|
||||||
|
int proxyId,
|
||||||
|
boolean attach
|
||||||
|
) {
|
||||||
|
if (attach) {
|
||||||
|
playerToProxy.put(targetPlayerUUID, proxyId);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[LeashProxyClient] Registered proxy {} for player {}",
|
||||||
|
proxyId,
|
||||||
|
targetPlayerUUID
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
playerToProxy.remove(targetPlayerUUID);
|
||||||
|
TiedUpMod.LOGGER.debug(
|
||||||
|
"[LeashProxyClient] Removed proxy for player {}",
|
||||||
|
targetPlayerUUID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX: Use ClientTickEvent instead of RenderLevelStageEvent.AFTER_ENTITIES.
|
||||||
|
* This positions proxies BEFORE rendering, eliminating the 1-frame lag
|
||||||
|
* that caused jittery leash rendering.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
// Only run at end of tick (after player position is updated)
|
||||||
|
if (event.phase != TickEvent.Phase.END) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToProxy.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc == null || mc.level == null || mc.isPaused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Level level = mc.level;
|
||||||
|
|
||||||
|
// Reposition each tracked proxy
|
||||||
|
for (Map.Entry<UUID, Integer> entry : playerToProxy.entrySet()) {
|
||||||
|
UUID playerUUID = entry.getKey();
|
||||||
|
int proxyId = entry.getValue();
|
||||||
|
|
||||||
|
Player playerEntity = level.getPlayerByUUID(playerUUID);
|
||||||
|
Entity proxyEntity = level.getEntity(proxyId);
|
||||||
|
|
||||||
|
if (playerEntity != null && proxyEntity != null) {
|
||||||
|
// FIX: Calculate Y offset based on bind type (dogwalk vs normal)
|
||||||
|
double yOffset = calculateYOffset(playerEntity);
|
||||||
|
|
||||||
|
// Use current position (interpolation will be handled by renderer)
|
||||||
|
double x = playerEntity.getX();
|
||||||
|
double y = playerEntity.getY() + yOffset;
|
||||||
|
double z = playerEntity.getZ() - 0.15;
|
||||||
|
|
||||||
|
// Set proxy position
|
||||||
|
proxyEntity.setPos(x, y, z);
|
||||||
|
|
||||||
|
// Update old positions for smooth interpolation
|
||||||
|
proxyEntity.xOld = proxyEntity.xo = x;
|
||||||
|
proxyEntity.yOld = proxyEntity.yo = y;
|
||||||
|
proxyEntity.zOld = proxyEntity.zo = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Y offset based on player's bind type.
|
||||||
|
* Dogwalk (DOGBINDER) uses lower offset for 4-legged pose.
|
||||||
|
*/
|
||||||
|
private static double calculateYOffset(Player player) {
|
||||||
|
IBondageState state = KidnappedHelper.getKidnappedState(player);
|
||||||
|
if (state != null && state.isTiedUp()) {
|
||||||
|
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (
|
||||||
|
!bind.isEmpty() &&
|
||||||
|
bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER)
|
||||||
|
) {
|
||||||
|
return DOGWALK_Y_OFFSET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_Y_OFFSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all tracked proxies.
|
||||||
|
* Called when disconnecting from server.
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
playerToProxy.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.tiedup.remake.client.events;
|
||||||
|
|
||||||
|
import com.tiedup.remake.items.base.*;
|
||||||
|
import com.tiedup.remake.network.ModNetwork;
|
||||||
|
import com.tiedup.remake.network.selfbondage.PacketSelfBondage;
|
||||||
|
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side event handler for self-bondage input.
|
||||||
|
*
|
||||||
|
* Intercepts left-click when holding bondage items and
|
||||||
|
* sends packets continuously to the server to perform self-bondage.
|
||||||
|
*
|
||||||
|
* Self-bondage items:
|
||||||
|
* - Binds (rope, chain, etc.) - Self-tie (requires holding left-click)
|
||||||
|
* - Gags - Self-gag (if already tied, instant)
|
||||||
|
* - Blindfolds - Self-blindfold (if already tied, instant)
|
||||||
|
* - Mittens - Self-mitten (if already tied, instant)
|
||||||
|
* - Earplugs - Self-earplug (if already tied, instant)
|
||||||
|
* - Collar - NOT ALLOWED (cannot self-collar)
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = "tiedup",
|
||||||
|
value = Dist.CLIENT,
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||||
|
)
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class SelfBondageInputHandler {
|
||||||
|
|
||||||
|
/** Track if we're currently in self-bondage mode */
|
||||||
|
private static boolean isSelfBondageActive = false;
|
||||||
|
|
||||||
|
/** The hand we're using for self-bondage */
|
||||||
|
private static InteractionHand activeHand = null;
|
||||||
|
|
||||||
|
/** Tick counter for packet sending interval */
|
||||||
|
private static int tickCounter = 0;
|
||||||
|
|
||||||
|
/** Send packet every 4 ticks (5 times per second) for smooth progress */
|
||||||
|
private static final int PACKET_INTERVAL = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle left-click in empty air - START self-bondage.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onLeftClickEmpty(
|
||||||
|
PlayerInteractEvent.LeftClickEmpty event
|
||||||
|
) {
|
||||||
|
startSelfBondage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle left-click on block - START self-bondage (cancel block breaking).
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onLeftClickBlock(
|
||||||
|
PlayerInteractEvent.LeftClickBlock event
|
||||||
|
) {
|
||||||
|
if (!event.getLevel().isClientSide()) return;
|
||||||
|
|
||||||
|
ItemStack stack = event.getItemStack();
|
||||||
|
if (isSelfBondageItem(stack.getItem())) {
|
||||||
|
event.setCanceled(true);
|
||||||
|
startSelfBondage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start self-bondage mode if holding a bondage item.
|
||||||
|
*/
|
||||||
|
private static void startSelfBondage() {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return;
|
||||||
|
|
||||||
|
// Check main hand first, then off hand
|
||||||
|
InteractionHand hand = InteractionHand.MAIN_HAND;
|
||||||
|
ItemStack stack = player.getMainHandItem();
|
||||||
|
|
||||||
|
if (!isSelfBondageItem(stack.getItem())) {
|
||||||
|
stack = player.getOffhandItem();
|
||||||
|
hand = InteractionHand.OFF_HAND;
|
||||||
|
|
||||||
|
if (!isSelfBondageItem(stack.getItem())) {
|
||||||
|
return; // No bondage item in either hand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start self-bondage mode
|
||||||
|
isSelfBondageActive = true;
|
||||||
|
activeHand = hand;
|
||||||
|
tickCounter = 0;
|
||||||
|
|
||||||
|
// Send initial packet immediately
|
||||||
|
ModNetwork.sendToServer(new PacketSelfBondage(hand));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client tick - continuously send packets while attack button is held.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END) return;
|
||||||
|
if (!isSelfBondageActive) return;
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
LocalPlayer player = mc.player;
|
||||||
|
|
||||||
|
// Stop if conditions are no longer valid
|
||||||
|
if (player == null || mc.screen != null) {
|
||||||
|
stopSelfBondage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if attack button is still held
|
||||||
|
if (!mc.options.keyAttack.isDown()) {
|
||||||
|
stopSelfBondage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still holding bondage item in the active hand
|
||||||
|
ItemStack stack = player.getItemInHand(activeHand);
|
||||||
|
if (!isSelfBondageItem(stack.getItem())) {
|
||||||
|
stopSelfBondage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send packet at interval for continuous progress
|
||||||
|
tickCounter++;
|
||||||
|
if (tickCounter >= PACKET_INTERVAL) {
|
||||||
|
tickCounter = 0;
|
||||||
|
ModNetwork.sendToServer(new PacketSelfBondage(activeHand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop self-bondage mode.
|
||||||
|
*/
|
||||||
|
private static void stopSelfBondage() {
|
||||||
|
isSelfBondageActive = false;
|
||||||
|
activeHand = null;
|
||||||
|
tickCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item supports self-bondage.
|
||||||
|
* Collar is explicitly excluded.
|
||||||
|
*/
|
||||||
|
private static boolean isSelfBondageItem(Item item) {
|
||||||
|
// Collar cannot be self-equipped (V1 collar guard)
|
||||||
|
if (item instanceof ItemCollar) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 bondage items support self-bondage (left-click hold with tying duration)
|
||||||
|
if (item instanceof IV2BondageItem) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 bondage items (legacy)
|
||||||
|
return (
|
||||||
|
item instanceof ItemBind ||
|
||||||
|
item instanceof ItemGag ||
|
||||||
|
item instanceof ItemBlindfold ||
|
||||||
|
item instanceof ItemMittens ||
|
||||||
|
item instanceof ItemEarplugs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
786
src/main/java/com/tiedup/remake/client/gltf/GlbParser.java
Normal file
786
src/main/java/com/tiedup/remake/client/gltf/GlbParser.java
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser for binary .glb (glTF 2.0) files.
|
||||||
|
* Extracts mesh geometry, skinning data, bone hierarchy, and animations.
|
||||||
|
* Filters out meshes named "Player".
|
||||||
|
*/
|
||||||
|
public final class GlbParser {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
|
||||||
|
private static final int GLB_VERSION = 2;
|
||||||
|
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
|
||||||
|
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
|
||||||
|
|
||||||
|
private GlbParser() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a .glb file from an InputStream.
|
||||||
|
*
|
||||||
|
* @param input the input stream (will be fully read)
|
||||||
|
* @param debugName name for log messages
|
||||||
|
* @return parsed GltfData
|
||||||
|
* @throws IOException if the file is malformed or I/O fails
|
||||||
|
*/
|
||||||
|
public static GltfData parse(InputStream input, String debugName)
|
||||||
|
throws IOException {
|
||||||
|
byte[] allBytes = input.readAllBytes();
|
||||||
|
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
|
||||||
|
ByteOrder.LITTLE_ENDIAN
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Header --
|
||||||
|
int magic = buf.getInt();
|
||||||
|
if (magic != GLB_MAGIC) {
|
||||||
|
throw new IOException("Not a GLB file: " + debugName);
|
||||||
|
}
|
||||||
|
int version = buf.getInt();
|
||||||
|
if (version != GLB_VERSION) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unsupported GLB version " + version + " in " + debugName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
int totalLength = buf.getInt();
|
||||||
|
|
||||||
|
// -- JSON chunk --
|
||||||
|
int jsonChunkLength = buf.getInt();
|
||||||
|
int jsonChunkType = buf.getInt();
|
||||||
|
if (jsonChunkType != CHUNK_JSON) {
|
||||||
|
throw new IOException("Expected JSON chunk in " + debugName);
|
||||||
|
}
|
||||||
|
byte[] jsonBytes = new byte[jsonChunkLength];
|
||||||
|
buf.get(jsonBytes);
|
||||||
|
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
|
||||||
|
JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
|
||||||
|
|
||||||
|
// -- BIN chunk --
|
||||||
|
ByteBuffer binData = null;
|
||||||
|
if (buf.hasRemaining()) {
|
||||||
|
int binChunkLength = buf.getInt();
|
||||||
|
int binChunkType = buf.getInt();
|
||||||
|
if (binChunkType != CHUNK_BIN) {
|
||||||
|
throw new IOException("Expected BIN chunk in " + debugName);
|
||||||
|
}
|
||||||
|
byte[] binBytes = new byte[binChunkLength];
|
||||||
|
buf.get(binBytes);
|
||||||
|
binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
}
|
||||||
|
if (binData == null) {
|
||||||
|
throw new IOException("No BIN chunk in " + debugName);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray accessors = root.getAsJsonArray("accessors");
|
||||||
|
JsonArray bufferViews = root.getAsJsonArray("bufferViews");
|
||||||
|
JsonArray nodes = root.getAsJsonArray("nodes");
|
||||||
|
JsonArray meshes = root.getAsJsonArray("meshes");
|
||||||
|
|
||||||
|
// -- Find skin --
|
||||||
|
JsonArray skins = root.getAsJsonArray("skins");
|
||||||
|
if (skins == null || skins.size() == 0) {
|
||||||
|
throw new IOException("No skins found in " + debugName);
|
||||||
|
}
|
||||||
|
JsonObject skin = skins.get(0).getAsJsonObject();
|
||||||
|
JsonArray skinJoints = skin.getAsJsonArray("joints");
|
||||||
|
|
||||||
|
// Filter skin joints to only include known deforming bones
|
||||||
|
List<Integer> filteredJointNodes = new ArrayList<>();
|
||||||
|
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
|
||||||
|
java.util.Arrays.fill(skinJointRemap, -1);
|
||||||
|
for (int j = 0; j < skinJoints.size(); j++) {
|
||||||
|
int nodeIdx = skinJoints.get(j).getAsInt();
|
||||||
|
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||||
|
String name = node.has("name")
|
||||||
|
? node.get("name").getAsString()
|
||||||
|
: "joint_" + j;
|
||||||
|
if (GltfBoneMapper.isKnownBone(name)) {
|
||||||
|
skinJointRemap[j] = filteredJointNodes.size();
|
||||||
|
filteredJointNodes.add(nodeIdx);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Skipping non-deforming bone: '{}' (node {})",
|
||||||
|
name,
|
||||||
|
nodeIdx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int jointCount = filteredJointNodes.size();
|
||||||
|
String[] jointNames = new String[jointCount];
|
||||||
|
int[] parentJointIndices = new int[jointCount];
|
||||||
|
Quaternionf[] restRotations = new Quaternionf[jointCount];
|
||||||
|
Vector3f[] restTranslations = new Vector3f[jointCount];
|
||||||
|
|
||||||
|
// Map node index -> joint index (filtered)
|
||||||
|
int[] nodeToJoint = new int[nodes.size()];
|
||||||
|
java.util.Arrays.fill(nodeToJoint, -1);
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
int nodeIdx = filteredJointNodes.get(j);
|
||||||
|
nodeToJoint[nodeIdx] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read joint names, rest pose, and build parent mapping
|
||||||
|
java.util.Arrays.fill(parentJointIndices, -1);
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
int nodeIdx = filteredJointNodes.get(j);
|
||||||
|
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||||
|
|
||||||
|
jointNames[j] = node.has("name")
|
||||||
|
? node.get("name").getAsString()
|
||||||
|
: "joint_" + j;
|
||||||
|
|
||||||
|
// Rest rotation
|
||||||
|
if (node.has("rotation")) {
|
||||||
|
JsonArray r = node.getAsJsonArray("rotation");
|
||||||
|
restRotations[j] = new Quaternionf(
|
||||||
|
r.get(0).getAsFloat(),
|
||||||
|
r.get(1).getAsFloat(),
|
||||||
|
r.get(2).getAsFloat(),
|
||||||
|
r.get(3).getAsFloat()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
restRotations[j] = new Quaternionf(); // identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest translation
|
||||||
|
if (node.has("translation")) {
|
||||||
|
JsonArray t = node.getAsJsonArray("translation");
|
||||||
|
restTranslations[j] = new Vector3f(
|
||||||
|
t.get(0).getAsFloat(),
|
||||||
|
t.get(1).getAsFloat(),
|
||||||
|
t.get(2).getAsFloat()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
restTranslations[j] = new Vector3f();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build parent indices by traversing node children
|
||||||
|
for (int ni = 0; ni < nodes.size(); ni++) {
|
||||||
|
JsonObject node = nodes.get(ni).getAsJsonObject();
|
||||||
|
if (node.has("children")) {
|
||||||
|
int parentJoint = nodeToJoint[ni];
|
||||||
|
JsonArray children = node.getAsJsonArray("children");
|
||||||
|
for (JsonElement child : children) {
|
||||||
|
int childNodeIdx = child.getAsInt();
|
||||||
|
int childJoint = nodeToJoint[childNodeIdx];
|
||||||
|
if (childJoint >= 0 && parentJoint >= 0) {
|
||||||
|
parentJointIndices[childJoint] = parentJoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Inverse Bind Matrices --
|
||||||
|
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
|
||||||
|
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
|
||||||
|
if (skin.has("inverseBindMatrices")) {
|
||||||
|
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
|
||||||
|
float[] ibmData = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
ibmAccessor
|
||||||
|
);
|
||||||
|
for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
|
||||||
|
int newJ = skinJointRemap[origJ];
|
||||||
|
if (newJ >= 0) {
|
||||||
|
inverseBindMatrices[newJ] = new Matrix4f();
|
||||||
|
inverseBindMatrices[newJ].set(ibmData, origJ * 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
inverseBindMatrices[j] = new Matrix4f(); // identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
|
||||||
|
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
|
||||||
|
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
|
||||||
|
int targetMeshIdx = -1;
|
||||||
|
if (meshes != null) {
|
||||||
|
for (int mi = 0; mi < meshes.size(); mi++) {
|
||||||
|
JsonObject mesh = meshes.get(mi).getAsJsonObject();
|
||||||
|
String meshName = mesh.has("name")
|
||||||
|
? mesh.get("name").getAsString()
|
||||||
|
: "";
|
||||||
|
if (!"Player".equals(meshName)) {
|
||||||
|
targetMeshIdx = mi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Parse root material names (for tint channel detection) --
|
||||||
|
String[] materialNames = GlbParserUtils.parseMaterialNames(root);
|
||||||
|
|
||||||
|
// Mesh data: empty arrays if no mesh found (animation-only GLB)
|
||||||
|
float[] positions;
|
||||||
|
float[] normals;
|
||||||
|
float[] texCoords;
|
||||||
|
int[] indices;
|
||||||
|
int vertexCount;
|
||||||
|
int[] meshJoints;
|
||||||
|
float[] weights;
|
||||||
|
List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();
|
||||||
|
|
||||||
|
if (targetMeshIdx >= 0) {
|
||||||
|
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
|
||||||
|
JsonArray primitives = mesh.getAsJsonArray("primitives");
|
||||||
|
|
||||||
|
// -- Accumulate vertex data from ALL primitives --
|
||||||
|
List<float[]> allPositions = new ArrayList<>();
|
||||||
|
List<float[]> allNormals = new ArrayList<>();
|
||||||
|
List<float[]> allTexCoords = new ArrayList<>();
|
||||||
|
List<int[]> allJoints = new ArrayList<>();
|
||||||
|
List<float[]> allWeights = new ArrayList<>();
|
||||||
|
int cumulativeVertexCount = 0;
|
||||||
|
|
||||||
|
for (int pi = 0; pi < primitives.size(); pi++) {
|
||||||
|
JsonObject primitive = primitives.get(pi).getAsJsonObject();
|
||||||
|
JsonObject attributes = primitive.getAsJsonObject("attributes");
|
||||||
|
|
||||||
|
// -- Read this primitive's vertex data --
|
||||||
|
float[] primPositions = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
attributes.get("POSITION").getAsInt()
|
||||||
|
);
|
||||||
|
float[] primNormals = attributes.has("NORMAL")
|
||||||
|
? GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
attributes.get("NORMAL").getAsInt()
|
||||||
|
)
|
||||||
|
: new float[primPositions.length];
|
||||||
|
float[] primTexCoords = attributes.has("TEXCOORD_0")
|
||||||
|
? GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
attributes.get("TEXCOORD_0").getAsInt()
|
||||||
|
)
|
||||||
|
: new float[(primPositions.length / 3) * 2];
|
||||||
|
|
||||||
|
int primVertexCount = primPositions.length / 3;
|
||||||
|
|
||||||
|
// -- Read this primitive's indices (offset by cumulative vertex count) --
|
||||||
|
int[] primIndices;
|
||||||
|
if (primitive.has("indices")) {
|
||||||
|
primIndices = GlbParserUtils.readIntAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
primitive.get("indices").getAsInt()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Non-indexed: generate sequential indices
|
||||||
|
primIndices = new int[primVertexCount];
|
||||||
|
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
|
||||||
|
i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset indices by cumulative vertex count from prior primitives
|
||||||
|
if (cumulativeVertexCount > 0) {
|
||||||
|
for (int i = 0; i < primIndices.length; i++) {
|
||||||
|
primIndices[i] += cumulativeVertexCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Read skinning attributes for this primitive --
|
||||||
|
int[] primJoints = new int[primVertexCount * 4];
|
||||||
|
float[] primWeights = new float[primVertexCount * 4];
|
||||||
|
|
||||||
|
if (attributes.has("JOINTS_0")) {
|
||||||
|
primJoints = GlbParserUtils.readIntAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
attributes.get("JOINTS_0").getAsInt()
|
||||||
|
);
|
||||||
|
// Remap vertex joint indices from original skin order to filtered order
|
||||||
|
for (int i = 0; i < primJoints.length; i++) {
|
||||||
|
int origIdx = primJoints[i];
|
||||||
|
if (origIdx >= 0 && origIdx < skinJointRemap.length) {
|
||||||
|
primJoints[i] =
|
||||||
|
skinJointRemap[origIdx] >= 0
|
||||||
|
? skinJointRemap[origIdx]
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
primJoints[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attributes.has("WEIGHTS_0")) {
|
||||||
|
primWeights = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
attributes.get("WEIGHTS_0").getAsInt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Resolve material name and tint channel --
|
||||||
|
String matName = null;
|
||||||
|
if (primitive.has("material")) {
|
||||||
|
int matIdx = primitive.get("material").getAsInt();
|
||||||
|
if (matIdx >= 0 && matIdx < materialNames.length) {
|
||||||
|
matName = materialNames[matIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean isTintable =
|
||||||
|
matName != null && matName.startsWith("tintable_");
|
||||||
|
String tintChannel = isTintable ? matName : null;
|
||||||
|
|
||||||
|
parsedPrimitives.add(
|
||||||
|
new GltfData.Primitive(
|
||||||
|
primIndices,
|
||||||
|
matName,
|
||||||
|
isTintable,
|
||||||
|
tintChannel
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
allPositions.add(primPositions);
|
||||||
|
allNormals.add(primNormals);
|
||||||
|
allTexCoords.add(primTexCoords);
|
||||||
|
allJoints.add(primJoints);
|
||||||
|
allWeights.add(primWeights);
|
||||||
|
cumulativeVertexCount += primVertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Flatten accumulated data into single arrays --
|
||||||
|
vertexCount = cumulativeVertexCount;
|
||||||
|
positions = GlbParserUtils.flattenFloats(allPositions);
|
||||||
|
normals = GlbParserUtils.flattenFloats(allNormals);
|
||||||
|
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
|
||||||
|
meshJoints = GlbParserUtils.flattenInts(allJoints);
|
||||||
|
weights = GlbParserUtils.flattenFloats(allWeights);
|
||||||
|
|
||||||
|
// Build union of all primitive indices (for backward-compat indices() accessor)
|
||||||
|
int totalIndices = 0;
|
||||||
|
for (GltfData.Primitive p : parsedPrimitives)
|
||||||
|
totalIndices += p.indices().length;
|
||||||
|
indices = new int[totalIndices];
|
||||||
|
int offset = 0;
|
||||||
|
for (GltfData.Primitive p : parsedPrimitives) {
|
||||||
|
System.arraycopy(
|
||||||
|
p.indices(),
|
||||||
|
0,
|
||||||
|
indices,
|
||||||
|
offset,
|
||||||
|
p.indices().length
|
||||||
|
);
|
||||||
|
offset += p.indices().length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Animation-only GLB: no mesh data
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] No mesh found in '{}' (animation-only GLB)",
|
||||||
|
debugName
|
||||||
|
);
|
||||||
|
positions = new float[0];
|
||||||
|
normals = new float[0];
|
||||||
|
texCoords = new float[0];
|
||||||
|
indices = new int[0];
|
||||||
|
vertexCount = 0;
|
||||||
|
meshJoints = new int[0];
|
||||||
|
weights = new float[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Read ALL animations --
|
||||||
|
Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
|
||||||
|
JsonArray animations = root.getAsJsonArray("animations");
|
||||||
|
if (animations != null) {
|
||||||
|
for (int ai = 0; ai < animations.size(); ai++) {
|
||||||
|
JsonObject anim = animations.get(ai).getAsJsonObject();
|
||||||
|
String animName = anim.has("name")
|
||||||
|
? anim.get("name").getAsString()
|
||||||
|
: "animation_" + ai;
|
||||||
|
// Strip the "ArmatureName|" prefix if present (Blender convention)
|
||||||
|
if (animName.contains("|")) {
|
||||||
|
animName = animName.substring(
|
||||||
|
animName.lastIndexOf('|') + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GltfData.AnimationClip clip = parseAnimation(
|
||||||
|
anim,
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
nodeToJoint,
|
||||||
|
jointCount
|
||||||
|
);
|
||||||
|
if (clip != null) {
|
||||||
|
allClips.put(animName, clip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default animation = first clip (for backward compat)
|
||||||
|
GltfData.AnimationClip animClip = allClips.isEmpty()
|
||||||
|
? null
|
||||||
|
: allClips.values().iterator().next();
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
|
||||||
|
debugName,
|
||||||
|
vertexCount,
|
||||||
|
indices.length,
|
||||||
|
jointCount,
|
||||||
|
allClips.size()
|
||||||
|
);
|
||||||
|
for (String name : allClips.keySet()) {
|
||||||
|
LOGGER.debug("[GltfPipeline] animation: '{}'", name);
|
||||||
|
}
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
Quaternionf rq = restRotations[j];
|
||||||
|
Vector3f rt = restTranslations[j];
|
||||||
|
LOGGER.debug(
|
||||||
|
String.format(
|
||||||
|
"[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
|
||||||
|
j,
|
||||||
|
jointNames[j],
|
||||||
|
parentJointIndices[j],
|
||||||
|
rq.x,
|
||||||
|
rq.y,
|
||||||
|
rq.z,
|
||||||
|
rq.w,
|
||||||
|
rt.x,
|
||||||
|
rt.y,
|
||||||
|
rt.z
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log animation translation channels for default clip (BEFORE MC conversion)
|
||||||
|
if (animClip != null && animClip.translations() != null) {
|
||||||
|
Vector3f[][] animTrans = animClip.translations();
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
if (j < animTrans.length && animTrans[j] != null) {
|
||||||
|
Vector3f at = animTrans[j][0]; // first frame
|
||||||
|
Vector3f rt = restTranslations[j];
|
||||||
|
LOGGER.debug(
|
||||||
|
String.format(
|
||||||
|
"[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
|
||||||
|
j,
|
||||||
|
jointNames[j],
|
||||||
|
at.x,
|
||||||
|
at.y,
|
||||||
|
at.z,
|
||||||
|
rt.x,
|
||||||
|
rt.y,
|
||||||
|
rt.z,
|
||||||
|
at.x - rt.x,
|
||||||
|
at.y - rt.y,
|
||||||
|
at.z - rt.z
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Default animation has NO translation channels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
|
||||||
|
// MC model space faces +Z just like glTF, so delta quaternions for ModelPart
|
||||||
|
// rotation should be computed from raw glTF data, not from the converted data.
|
||||||
|
Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
rawRestRotations[j] = new Quaternionf(restRotations[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build raw copies of ALL animation clips (before MC conversion)
|
||||||
|
Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<
|
||||||
|
String,
|
||||||
|
GltfData.AnimationClip
|
||||||
|
> entry : allClips.entrySet()) {
|
||||||
|
rawAllClips.put(
|
||||||
|
entry.getKey(),
|
||||||
|
GlbParserUtils.deepCopyClip(entry.getValue())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty()
|
||||||
|
? null
|
||||||
|
: rawAllClips.values().iterator().next();
|
||||||
|
|
||||||
|
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
|
||||||
|
// This is a 180° rotation around Y: negate X and Z for all spatial data
|
||||||
|
// Convert ALL animation clips to MC space
|
||||||
|
for (GltfData.AnimationClip clip : allClips.values()) {
|
||||||
|
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
|
||||||
|
}
|
||||||
|
convertToMinecraftSpace(
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
restTranslations,
|
||||||
|
restRotations,
|
||||||
|
inverseBindMatrices,
|
||||||
|
null,
|
||||||
|
jointCount
|
||||||
|
); // pass null — clips already converted above
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Converted all data to Minecraft coordinate space"
|
||||||
|
);
|
||||||
|
|
||||||
|
return new GltfData(
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
texCoords,
|
||||||
|
indices,
|
||||||
|
meshJoints,
|
||||||
|
weights,
|
||||||
|
jointNames,
|
||||||
|
parentJointIndices,
|
||||||
|
inverseBindMatrices,
|
||||||
|
restRotations,
|
||||||
|
restTranslations,
|
||||||
|
rawRestRotations,
|
||||||
|
rawAnimClip,
|
||||||
|
animClip,
|
||||||
|
allClips,
|
||||||
|
rawAllClips,
|
||||||
|
parsedPrimitives,
|
||||||
|
vertexCount,
|
||||||
|
jointCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Animation parsing ----
|
||||||
|
|
||||||
|
private static GltfData.AnimationClip parseAnimation(
|
||||||
|
JsonObject animation,
|
||||||
|
JsonArray accessors,
|
||||||
|
JsonArray bufferViews,
|
||||||
|
ByteBuffer binData,
|
||||||
|
int[] nodeToJoint,
|
||||||
|
int jointCount
|
||||||
|
) {
|
||||||
|
JsonArray channels = animation.getAsJsonArray("channels");
|
||||||
|
JsonArray samplers = animation.getAsJsonArray("samplers");
|
||||||
|
|
||||||
|
// Collect rotation and translation channels
|
||||||
|
List<Integer> rotJoints = new ArrayList<>();
|
||||||
|
List<float[]> rotTimestamps = new ArrayList<>();
|
||||||
|
List<Quaternionf[]> rotValues = new ArrayList<>();
|
||||||
|
|
||||||
|
List<Integer> transJoints = new ArrayList<>();
|
||||||
|
List<float[]> transTimestamps = new ArrayList<>();
|
||||||
|
List<Vector3f[]> transValues = new ArrayList<>();
|
||||||
|
|
||||||
|
for (JsonElement chElem : channels) {
|
||||||
|
JsonObject channel = chElem.getAsJsonObject();
|
||||||
|
JsonObject target = channel.getAsJsonObject("target");
|
||||||
|
String path = target.get("path").getAsString();
|
||||||
|
|
||||||
|
int nodeIdx = target.get("node").getAsInt();
|
||||||
|
if (
|
||||||
|
nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0
|
||||||
|
) continue;
|
||||||
|
int jointIdx = nodeToJoint[nodeIdx];
|
||||||
|
|
||||||
|
int samplerIdx = channel.get("sampler").getAsInt();
|
||||||
|
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
|
||||||
|
|
||||||
|
float[] times = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
sampler.get("input").getAsInt()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("rotation".equals(path)) {
|
||||||
|
float[] quats = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
sampler.get("output").getAsInt()
|
||||||
|
);
|
||||||
|
Quaternionf[] qArr = new Quaternionf[times.length];
|
||||||
|
for (int i = 0; i < times.length; i++) {
|
||||||
|
qArr[i] = new Quaternionf(
|
||||||
|
quats[i * 4],
|
||||||
|
quats[i * 4 + 1],
|
||||||
|
quats[i * 4 + 2],
|
||||||
|
quats[i * 4 + 3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rotJoints.add(jointIdx);
|
||||||
|
rotTimestamps.add(times);
|
||||||
|
rotValues.add(qArr);
|
||||||
|
} else if ("translation".equals(path)) {
|
||||||
|
float[] vecs = GlbParserUtils.readFloatAccessor(
|
||||||
|
accessors,
|
||||||
|
bufferViews,
|
||||||
|
binData,
|
||||||
|
sampler.get("output").getAsInt()
|
||||||
|
);
|
||||||
|
Vector3f[] tArr = new Vector3f[times.length];
|
||||||
|
for (int i = 0; i < times.length; i++) {
|
||||||
|
tArr[i] = new Vector3f(
|
||||||
|
vecs[i * 3],
|
||||||
|
vecs[i * 3 + 1],
|
||||||
|
vecs[i * 3 + 2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
transJoints.add(jointIdx);
|
||||||
|
transTimestamps.add(times);
|
||||||
|
transValues.add(tArr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
|
||||||
|
|
||||||
|
// Use the first available channel's timestamps as reference
|
||||||
|
float[] timestamps = !rotTimestamps.isEmpty()
|
||||||
|
? rotTimestamps.get(0)
|
||||||
|
: transTimestamps.get(0);
|
||||||
|
int frameCount = timestamps.length;
|
||||||
|
|
||||||
|
// Build per-joint rotation arrays (null if no animation for that joint)
|
||||||
|
Quaternionf[][] rotations = new Quaternionf[jointCount][];
|
||||||
|
for (int i = 0; i < rotJoints.size(); i++) {
|
||||||
|
int jIdx = rotJoints.get(i);
|
||||||
|
Quaternionf[] vals = rotValues.get(i);
|
||||||
|
rotations[jIdx] = new Quaternionf[frameCount];
|
||||||
|
for (int f = 0; f < frameCount; f++) {
|
||||||
|
rotations[jIdx][f] =
|
||||||
|
f < vals.length ? vals[f] : vals[vals.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-joint translation arrays (null if no animation for that joint)
|
||||||
|
Vector3f[][] translations = new Vector3f[jointCount][];
|
||||||
|
for (int i = 0; i < transJoints.size(); i++) {
|
||||||
|
int jIdx = transJoints.get(i);
|
||||||
|
Vector3f[] vals = transValues.get(i);
|
||||||
|
translations[jIdx] = new Vector3f[frameCount];
|
||||||
|
for (int f = 0; f < frameCount; f++) {
|
||||||
|
translations[jIdx][f] =
|
||||||
|
f < vals.length
|
||||||
|
? new Vector3f(vals[f])
|
||||||
|
: new Vector3f(vals[vals.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log translation channels found
|
||||||
|
if (!transJoints.isEmpty()) {
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Animation has {} translation channel(s)",
|
||||||
|
transJoints.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GltfData.AnimationClip(
|
||||||
|
timestamps,
|
||||||
|
rotations,
|
||||||
|
translations,
|
||||||
|
frameCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Coordinate system conversion ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert all spatial data from glTF space to MC model-def space.
|
||||||
|
* The Blender-exported character faces -Z in glTF, same as MC model-def.
|
||||||
|
* Only X (right→left) and Y (up→down) differ between the two spaces.
|
||||||
|
* Equivalent to a 180° rotation around Z: negate X and Y components.
|
||||||
|
*
|
||||||
|
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
|
||||||
|
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
|
||||||
|
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
|
||||||
|
*/
|
||||||
|
private static void convertToMinecraftSpace(
|
||||||
|
float[] positions,
|
||||||
|
float[] normals,
|
||||||
|
Vector3f[] restTranslations,
|
||||||
|
Quaternionf[] restRotations,
|
||||||
|
Matrix4f[] inverseBindMatrices,
|
||||||
|
GltfData.AnimationClip animClip,
|
||||||
|
int jointCount
|
||||||
|
) {
|
||||||
|
// Vertex positions: negate X and Y
|
||||||
|
for (int i = 0; i < positions.length; i += 3) {
|
||||||
|
positions[i] = -positions[i]; // X
|
||||||
|
positions[i + 1] = -positions[i + 1]; // Y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex normals: negate X and Y
|
||||||
|
for (int i = 0; i < normals.length; i += 3) {
|
||||||
|
normals[i] = -normals[i];
|
||||||
|
normals[i + 1] = -normals[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest translations: negate X and Y
|
||||||
|
for (Vector3f t : restTranslations) {
|
||||||
|
t.x = -t.x;
|
||||||
|
t.y = -t.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest rotations: conjugate by 180° Z = negate qx and qy
|
||||||
|
for (Quaternionf q : restRotations) {
|
||||||
|
q.x = -q.x;
|
||||||
|
q.y = -q.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
|
||||||
|
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
|
||||||
|
Matrix4f temp = new Matrix4f();
|
||||||
|
for (Matrix4f ibm : inverseBindMatrices) {
|
||||||
|
temp.set(C).mul(ibm).mul(C);
|
||||||
|
ibm.set(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation quaternions: same conjugation
|
||||||
|
if (animClip != null) {
|
||||||
|
Quaternionf[][] rotations = animClip.rotations();
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
if (j < rotations.length && rotations[j] != null) {
|
||||||
|
for (Quaternionf q : rotations[j]) {
|
||||||
|
q.x = -q.x;
|
||||||
|
q.y = -q.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation translations: negate X and Y (same as rest translations)
|
||||||
|
Vector3f[][] translations = animClip.translations();
|
||||||
|
if (translations != null) {
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
if (j < translations.length && translations[j] != null) {
|
||||||
|
for (Vector3f t : translations[j]) {
|
||||||
|
t.x = -t.x;
|
||||||
|
t.y = -t.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java
Normal file
298
src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.List;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared stateless utilities for parsing binary glTF (.glb) files.
|
||||||
|
*
|
||||||
|
* <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
|
||||||
|
* and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
|
||||||
|
* (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
|
||||||
|
* duplication between the two parsers.</p>
|
||||||
|
*
|
||||||
|
* <p>All methods are pure functions (no state, no side effects).</p>
|
||||||
|
*/
|
||||||
|
public final class GlbParserUtils {
|
||||||
|
|
||||||
|
// glTF component type constants
|
||||||
|
public static final int BYTE = 5120;
|
||||||
|
public static final int UNSIGNED_BYTE = 5121;
|
||||||
|
public static final int SHORT = 5122;
|
||||||
|
public static final int UNSIGNED_SHORT = 5123;
|
||||||
|
public static final int UNSIGNED_INT = 5125;
|
||||||
|
public static final int FLOAT = 5126;
|
||||||
|
|
||||||
|
private GlbParserUtils() {}
|
||||||
|
|
||||||
|
// ---- Material name parsing ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the root "materials" array and extract each material's "name" field.
|
||||||
|
* Returns an empty array if no materials are present.
|
||||||
|
*/
|
||||||
|
public static String[] parseMaterialNames(JsonObject root) {
|
||||||
|
if (!root.has("materials") || !root.get("materials").isJsonArray()) {
|
||||||
|
return new String[0];
|
||||||
|
}
|
||||||
|
JsonArray materials = root.getAsJsonArray("materials");
|
||||||
|
String[] names = new String[materials.size()];
|
||||||
|
for (int i = 0; i < materials.size(); i++) {
|
||||||
|
JsonObject mat = materials.get(i).getAsJsonObject();
|
||||||
|
names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Array flattening utilities ----
|
||||||
|
|
||||||
|
public static float[] flattenFloats(List<float[]> arrays) {
|
||||||
|
int total = 0;
|
||||||
|
for (float[] a : arrays) total += a.length;
|
||||||
|
float[] result = new float[total];
|
||||||
|
int offset = 0;
|
||||||
|
for (float[] a : arrays) {
|
||||||
|
System.arraycopy(a, 0, result, offset, a.length);
|
||||||
|
offset += a.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int[] flattenInts(List<int[]> arrays) {
|
||||||
|
int total = 0;
|
||||||
|
for (int[] a : arrays) total += a.length;
|
||||||
|
int[] result = new int[total];
|
||||||
|
int offset = 0;
|
||||||
|
for (int[] a : arrays) {
|
||||||
|
System.arraycopy(a, 0, result, offset, a.length);
|
||||||
|
offset += a.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Accessor reading utilities ----
|
||||||
|
|
||||||
|
public static float[] readFloatAccessor(
|
||||||
|
JsonArray accessors,
|
||||||
|
JsonArray bufferViews,
|
||||||
|
ByteBuffer binData,
|
||||||
|
int accessorIdx
|
||||||
|
) {
|
||||||
|
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
|
||||||
|
int count = accessor.get("count").getAsInt();
|
||||||
|
int componentType = accessor.get("componentType").getAsInt();
|
||||||
|
String type = accessor.get("type").getAsString();
|
||||||
|
int components = typeComponents(type);
|
||||||
|
|
||||||
|
int bvIdx = accessor.get("bufferView").getAsInt();
|
||||||
|
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
|
||||||
|
int byteOffset =
|
||||||
|
(bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
|
||||||
|
(accessor.has("byteOffset")
|
||||||
|
? accessor.get("byteOffset").getAsInt()
|
||||||
|
: 0);
|
||||||
|
int byteStride = bv.has("byteStride")
|
||||||
|
? bv.get("byteStride").getAsInt()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
int totalElements = count * components;
|
||||||
|
float[] result = new float[totalElements];
|
||||||
|
|
||||||
|
int componentSize = componentByteSize(componentType);
|
||||||
|
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int pos = byteOffset + i * stride;
|
||||||
|
for (int c = 0; c < components; c++) {
|
||||||
|
binData.position(pos + c * componentSize);
|
||||||
|
result[i * components + c] = readComponentAsFloat(
|
||||||
|
binData,
|
||||||
|
componentType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int[] readIntAccessor(
|
||||||
|
JsonArray accessors,
|
||||||
|
JsonArray bufferViews,
|
||||||
|
ByteBuffer binData,
|
||||||
|
int accessorIdx
|
||||||
|
) {
|
||||||
|
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
|
||||||
|
int count = accessor.get("count").getAsInt();
|
||||||
|
int componentType = accessor.get("componentType").getAsInt();
|
||||||
|
String type = accessor.get("type").getAsString();
|
||||||
|
int components = typeComponents(type);
|
||||||
|
|
||||||
|
int bvIdx = accessor.get("bufferView").getAsInt();
|
||||||
|
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
|
||||||
|
int byteOffset =
|
||||||
|
(bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
|
||||||
|
(accessor.has("byteOffset")
|
||||||
|
? accessor.get("byteOffset").getAsInt()
|
||||||
|
: 0);
|
||||||
|
int byteStride = bv.has("byteStride")
|
||||||
|
? bv.get("byteStride").getAsInt()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
int totalElements = count * components;
|
||||||
|
int[] result = new int[totalElements];
|
||||||
|
|
||||||
|
int componentSize = componentByteSize(componentType);
|
||||||
|
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int pos = byteOffset + i * stride;
|
||||||
|
for (int c = 0; c < components; c++) {
|
||||||
|
binData.position(pos + c * componentSize);
|
||||||
|
result[i * components + c] = readComponentAsInt(
|
||||||
|
binData,
|
||||||
|
componentType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float readComponentAsFloat(
|
||||||
|
ByteBuffer buf,
|
||||||
|
int componentType
|
||||||
|
) {
|
||||||
|
return switch (componentType) {
|
||||||
|
case FLOAT -> buf.getFloat();
|
||||||
|
case BYTE -> buf.get() / 127.0f;
|
||||||
|
case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
|
||||||
|
case SHORT -> buf.getShort() / 32767.0f;
|
||||||
|
case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
|
||||||
|
case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) /
|
||||||
|
(float) 0xFFFFFFFFL;
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"Unknown component type: " + componentType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int readComponentAsInt(ByteBuffer buf, int componentType) {
|
||||||
|
return switch (componentType) {
|
||||||
|
case BYTE -> buf.get();
|
||||||
|
case UNSIGNED_BYTE -> buf.get() & 0xFF;
|
||||||
|
case SHORT -> buf.getShort();
|
||||||
|
case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
|
||||||
|
case UNSIGNED_INT -> buf.getInt();
|
||||||
|
case FLOAT -> (int) buf.getFloat();
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"Unknown component type: " + componentType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int typeComponents(String type) {
|
||||||
|
return switch (type) {
|
||||||
|
case "SCALAR" -> 1;
|
||||||
|
case "VEC2" -> 2;
|
||||||
|
case "VEC3" -> 3;
|
||||||
|
case "VEC4" -> 4;
|
||||||
|
case "MAT4" -> 16;
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"Unknown accessor type: " + type
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int componentByteSize(int componentType) {
|
||||||
|
return switch (componentType) {
|
||||||
|
case BYTE, UNSIGNED_BYTE -> 1;
|
||||||
|
case SHORT, UNSIGNED_SHORT -> 2;
|
||||||
|
case UNSIGNED_INT, FLOAT -> 4;
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"Unknown component type: " + componentType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Deep-copy utility ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-copy an AnimationClip (preserves original data before MC conversion).
|
||||||
|
*/
|
||||||
|
public static GltfData.AnimationClip deepCopyClip(
|
||||||
|
GltfData.AnimationClip clip
|
||||||
|
) {
|
||||||
|
Quaternionf[][] rawRotations =
|
||||||
|
new Quaternionf[clip.rotations().length][];
|
||||||
|
for (int j = 0; j < clip.rotations().length; j++) {
|
||||||
|
if (clip.rotations()[j] != null) {
|
||||||
|
rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
|
||||||
|
for (int f = 0; f < clip.rotations()[j].length; f++) {
|
||||||
|
rawRotations[j][f] = new Quaternionf(
|
||||||
|
clip.rotations()[j][f]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vector3f[][] rawTranslations = null;
|
||||||
|
if (clip.translations() != null) {
|
||||||
|
rawTranslations = new Vector3f[clip.translations().length][];
|
||||||
|
for (int j = 0; j < clip.translations().length; j++) {
|
||||||
|
if (clip.translations()[j] != null) {
|
||||||
|
rawTranslations[j] =
|
||||||
|
new Vector3f[clip.translations()[j].length];
|
||||||
|
for (int f = 0; f < clip.translations()[j].length; f++) {
|
||||||
|
rawTranslations[j][f] = new Vector3f(
|
||||||
|
clip.translations()[j][f]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new GltfData.AnimationClip(
|
||||||
|
clip.timestamps().clone(),
|
||||||
|
rawRotations,
|
||||||
|
rawTranslations,
|
||||||
|
clip.frameCount()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Coordinate system conversion ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an animation clip's rotations and translations to MC space.
|
||||||
|
* Negate qx/qy for rotations and negate tx/ty for translations.
|
||||||
|
*/
|
||||||
|
public static void convertAnimationToMinecraftSpace(
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
int jointCount
|
||||||
|
) {
|
||||||
|
if (clip == null) return;
|
||||||
|
|
||||||
|
Quaternionf[][] rotations = clip.rotations();
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
if (j < rotations.length && rotations[j] != null) {
|
||||||
|
for (Quaternionf q : rotations[j]) {
|
||||||
|
q.x = -q.x;
|
||||||
|
q.y = -q.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3f[][] translations = clip.translations();
|
||||||
|
if (translations != null) {
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
if (j < translations.length && translations[j] != null) {
|
||||||
|
for (Vector3f t : translations[j]) {
|
||||||
|
t.x = -t.x;
|
||||||
|
t.y = -t.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||||
|
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||||
|
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||||
|
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
|
||||||
|
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 Animation Applier -- manages dual-layer animation for V2 bondage items.
|
||||||
|
*
|
||||||
|
* <p>Orchestrates two PlayerAnimator layers simultaneously:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Context layer</b> (priority 40): base body posture (stand/sit/kneel/sneak/walk)
|
||||||
|
* with item-owned parts disabled, via {@link ContextAnimationFactory}</li>
|
||||||
|
* <li><b>Item layer</b> (priority 42): per-item GLB animation with only owned bones enabled,
|
||||||
|
* via {@link GltfPoseConverter#convertSelective}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Each equipped V2 item controls ONLY the bones matching its occupied body regions.
|
||||||
|
* Bones not owned by any item pass through from the context layer, which provides the
|
||||||
|
* appropriate base posture animation.
|
||||||
|
*
|
||||||
|
* <p>State tracking avoids redundant animation replays: a composite key of
|
||||||
|
* {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates.
|
||||||
|
*
|
||||||
|
* <p>Item animations are cached by {@code animSource#context#ownedParts} since the same
|
||||||
|
* GLB + context + owned parts always produces the same KeyframeAnimation.
|
||||||
|
*
|
||||||
|
* @see ContextAnimationFactory
|
||||||
|
* @see GlbAnimationResolver
|
||||||
|
* @see GltfPoseConverter#convertSelective
|
||||||
|
* @see BondageAnimationManager
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfAnimationApplier {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache of converted item-layer KeyframeAnimations.
|
||||||
|
* Keyed by "animSource#context#ownedPartsHash".
|
||||||
|
* Same GLB + same context + same owned parts = same KeyframeAnimation.
|
||||||
|
*/
|
||||||
|
private static final Map<String, KeyframeAnimation> itemAnimCache =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track which composite state is currently active per entity, to avoid redundant replays.
|
||||||
|
* Keyed by entity UUID, value is "animSource|context|sortedParts".
|
||||||
|
*/
|
||||||
|
private static final Map<UUID, String> activeStateKeys =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Track cache keys where GLB loading failed, to avoid per-tick retries. */
|
||||||
|
private static final Set<String> failedLoadKeys =
|
||||||
|
ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private GltfAnimationApplier() {}
|
||||||
|
|
||||||
|
// INIT (legacy)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy init method -- called by GltfClientSetup.
|
||||||
|
* No-op: layer registration is handled by {@link BondageAnimationManager#init()}.
|
||||||
|
*/
|
||||||
|
public static void init() {
|
||||||
|
// No-op: animation layers are managed by BondageAnimationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 DUAL-LAYER API
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the full V2 animation state: context layer + item layer.
|
||||||
|
*
|
||||||
|
* <p>Flow:
|
||||||
|
* <ol>
|
||||||
|
* <li>Build a composite state key and skip if unchanged</li>
|
||||||
|
* <li>Create/retrieve a context animation with disabledOnContext parts disabled,
|
||||||
|
* play on context layer via {@link BondageAnimationManager#playContext}</li>
|
||||||
|
* <li>Load the GLB (from {@code animationSource} or {@code modelLoc}),
|
||||||
|
* resolve the named animation via {@link GlbAnimationResolver#resolve},
|
||||||
|
* convert with selective parts via {@link GltfPoseConverter#convertSelective},
|
||||||
|
* play on item layer via {@link BondageAnimationManager#playDirect}</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The ownership model enables "free bone" animation: if a bone is not claimed
|
||||||
|
* by any item, the winning item can animate it IF its GLB has keyframes for that bone.
|
||||||
|
* This allows a straitjacket (ARMS+TORSO) to also animate free legs.</p>
|
||||||
|
*
|
||||||
|
* @param entity the entity to animate
|
||||||
|
* @param modelLoc the item's GLB model (for mesh rendering, and default animation source)
|
||||||
|
* @param animationSource separate GLB for animations (shared template), or null to use modelLoc
|
||||||
|
* @param context current animation context (STAND_IDLE, SIT_IDLE, etc.)
|
||||||
|
* @param ownership bone ownership: which parts this item owns vs other items
|
||||||
|
* @return true if the item layer animation was applied successfully
|
||||||
|
*/
|
||||||
|
public static boolean applyV2Animation(
|
||||||
|
LivingEntity entity,
|
||||||
|
ResourceLocation modelLoc,
|
||||||
|
@Nullable ResourceLocation animationSource,
|
||||||
|
AnimationContext context,
|
||||||
|
RegionBoneMapper.BoneOwnership ownership
|
||||||
|
) {
|
||||||
|
if (entity == null || modelLoc == null) return false;
|
||||||
|
|
||||||
|
ResourceLocation animSource =
|
||||||
|
animationSource != null ? animationSource : modelLoc;
|
||||||
|
// Cache key includes both owned and enabled parts for full disambiguation
|
||||||
|
String ownedKey = canonicalPartsKey(ownership.thisParts());
|
||||||
|
String enabledKey = canonicalPartsKey(ownership.enabledParts());
|
||||||
|
String partsKey = ownedKey + ";" + enabledKey;
|
||||||
|
|
||||||
|
// Build composite state key to avoid redundant updates
|
||||||
|
String stateKey = animSource + "|" + context.name() + "|" + partsKey;
|
||||||
|
String currentKey = activeStateKeys.get(entity.getUUID());
|
||||||
|
if (stateKey.equals(currentKey)) {
|
||||||
|
return true; // Already active, no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Layer 1: Context animation (base body posture) ===
|
||||||
|
// Parts owned by ANY item (this or others) are disabled on the context layer.
|
||||||
|
// Only free parts remain enabled on context.
|
||||||
|
KeyframeAnimation contextAnim = ContextAnimationFactory.create(
|
||||||
|
context,
|
||||||
|
ownership.disabledOnContext()
|
||||||
|
);
|
||||||
|
if (contextAnim != null) {
|
||||||
|
BondageAnimationManager.playContext(entity, contextAnim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Layer 2: Item animation (GLB pose with selective bones) ===
|
||||||
|
String itemCacheKey = buildItemCacheKey(animSource, context, partsKey);
|
||||||
|
|
||||||
|
// Skip if this GLB already failed to load
|
||||||
|
if (failedLoadKeys.contains(itemCacheKey)) {
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey);
|
||||||
|
if (itemAnim == null) {
|
||||||
|
GltfData animData = GlbAnimationResolver.resolveAnimationData(
|
||||||
|
modelLoc,
|
||||||
|
animationSource
|
||||||
|
);
|
||||||
|
if (animData == null) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"[GltfPipeline] Failed to load animation GLB: {}",
|
||||||
|
animSource
|
||||||
|
);
|
||||||
|
failedLoadKeys.add(itemCacheKey);
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Resolve which named animation to use (with fallback chain + variant selection)
|
||||||
|
String glbAnimName = GlbAnimationResolver.resolve(
|
||||||
|
animData,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
// Pass both owned parts and enabled parts (owned + free) for selective enabling
|
||||||
|
itemAnim = GltfPoseConverter.convertSelective(
|
||||||
|
animData,
|
||||||
|
glbAnimName,
|
||||||
|
ownership.thisParts(),
|
||||||
|
ownership.enabledParts()
|
||||||
|
);
|
||||||
|
itemAnimCache.put(itemCacheKey, itemAnim);
|
||||||
|
}
|
||||||
|
|
||||||
|
BondageAnimationManager.playDirect(entity, itemAnim);
|
||||||
|
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply V2 animation from ALL equipped items simultaneously.
|
||||||
|
*
|
||||||
|
* <p>Each item contributes keyframes for only its owned bones into a shared
|
||||||
|
* {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority)
|
||||||
|
* can additionally animate free bones if its GLB has keyframes for them.</p>
|
||||||
|
*
|
||||||
|
* @param entity the entity to animate
|
||||||
|
* @param items resolved V2 items with per-item ownership, sorted by priority desc
|
||||||
|
* @param context current animation context
|
||||||
|
* @param allOwnedParts union of all owned parts across all items
|
||||||
|
* @return true if the composite animation was applied
|
||||||
|
*/
|
||||||
|
public static boolean applyMultiItemV2Animation(
|
||||||
|
LivingEntity entity,
|
||||||
|
List<RegionBoneMapper.V2ItemAnimInfo> items,
|
||||||
|
AnimationContext context,
|
||||||
|
Set<String> allOwnedParts
|
||||||
|
) {
|
||||||
|
if (entity == null || items.isEmpty()) return false;
|
||||||
|
|
||||||
|
// Build composite state key
|
||||||
|
StringBuilder keyBuilder = new StringBuilder();
|
||||||
|
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
|
||||||
|
ResourceLocation src =
|
||||||
|
item.animSource() != null ? item.animSource() : item.modelLoc();
|
||||||
|
keyBuilder
|
||||||
|
.append(src)
|
||||||
|
.append(':')
|
||||||
|
.append(canonicalPartsKey(item.ownedParts()))
|
||||||
|
.append(';');
|
||||||
|
}
|
||||||
|
keyBuilder.append(context.name());
|
||||||
|
String stateKey = keyBuilder.toString();
|
||||||
|
|
||||||
|
String currentKey = activeStateKeys.get(entity.getUUID());
|
||||||
|
if (stateKey.equals(currentKey)) {
|
||||||
|
return true; // Already active
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Layer 1: Context animation ===
|
||||||
|
KeyframeAnimation contextAnim = ContextAnimationFactory.create(
|
||||||
|
context,
|
||||||
|
allOwnedParts
|
||||||
|
);
|
||||||
|
if (contextAnim != null) {
|
||||||
|
BondageAnimationManager.playContext(entity, contextAnim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Layer 2: Composite item animation ===
|
||||||
|
String compositeCacheKey = "multi#" + stateKey;
|
||||||
|
|
||||||
|
if (failedLoadKeys.contains(compositeCacheKey)) {
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
|
||||||
|
if (compositeAnim == null) {
|
||||||
|
KeyframeAnimation.AnimationBuilder builder =
|
||||||
|
new KeyframeAnimation.AnimationBuilder(
|
||||||
|
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
|
||||||
|
);
|
||||||
|
builder.beginTick = 0;
|
||||||
|
builder.endTick = 1;
|
||||||
|
builder.stopTick = 1;
|
||||||
|
builder.isLooped = true;
|
||||||
|
builder.returnTick = 0;
|
||||||
|
builder.name = "gltf_composite";
|
||||||
|
|
||||||
|
boolean anyLoaded = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
|
||||||
|
ResourceLocation animSource =
|
||||||
|
item.animSource() != null
|
||||||
|
? item.animSource()
|
||||||
|
: item.modelLoc();
|
||||||
|
|
||||||
|
GltfData animData = GlbAnimationResolver.resolveAnimationData(
|
||||||
|
item.modelLoc(),
|
||||||
|
item.animSource()
|
||||||
|
);
|
||||||
|
if (animData == null) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"[GltfPipeline] Failed to load GLB for multi-item: {}",
|
||||||
|
animSource
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String glbAnimName = GlbAnimationResolver.resolve(
|
||||||
|
animData,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
GltfData.AnimationClip rawClip;
|
||||||
|
if (glbAnimName != null) {
|
||||||
|
rawClip = animData.getRawAnimation(glbAnimName);
|
||||||
|
} else {
|
||||||
|
rawClip = null;
|
||||||
|
}
|
||||||
|
if (rawClip == null) {
|
||||||
|
rawClip = animData.rawGltfAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute effective parts: intersect animation_bones whitelist with ownedParts
|
||||||
|
// if the item declares per-animation bone filtering.
|
||||||
|
Set<String> effectiveParts = item.ownedParts();
|
||||||
|
if (glbAnimName != null && !item.animationBones().isEmpty()) {
|
||||||
|
Set<String> override = item
|
||||||
|
.animationBones()
|
||||||
|
.get(glbAnimName);
|
||||||
|
if (override != null) {
|
||||||
|
Set<String> filtered = new HashSet<>(override);
|
||||||
|
filtered.retainAll(item.ownedParts());
|
||||||
|
if (!filtered.isEmpty()) {
|
||||||
|
effectiveParts = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GltfPoseConverter.addBonesToBuilder(
|
||||||
|
builder,
|
||||||
|
animData,
|
||||||
|
rawClip,
|
||||||
|
effectiveParts
|
||||||
|
);
|
||||||
|
anyLoaded = true;
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
|
||||||
|
animSource,
|
||||||
|
item.ownedParts(),
|
||||||
|
effectiveParts,
|
||||||
|
glbAnimName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyLoaded) {
|
||||||
|
failedLoadKeys.add(compositeCacheKey);
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable only owned parts on the item layer.
|
||||||
|
// Free parts (head, body, etc. not owned by any item) are disabled here
|
||||||
|
// so they pass through to the context layer / vanilla animation.
|
||||||
|
String[] allPartNames = {
|
||||||
|
"head",
|
||||||
|
"body",
|
||||||
|
"rightArm",
|
||||||
|
"leftArm",
|
||||||
|
"rightLeg",
|
||||||
|
"leftLeg",
|
||||||
|
};
|
||||||
|
for (String partName : allPartNames) {
|
||||||
|
KeyframeAnimation.StateCollection part = getPartByName(
|
||||||
|
builder,
|
||||||
|
partName
|
||||||
|
);
|
||||||
|
if (part != null) {
|
||||||
|
if (allOwnedParts.contains(partName)) {
|
||||||
|
part.fullyEnablePart(false);
|
||||||
|
} else {
|
||||||
|
part.setEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compositeAnim = builder.build();
|
||||||
|
itemAnimCache.put(compositeCacheKey, compositeAnim);
|
||||||
|
}
|
||||||
|
|
||||||
|
BondageAnimationManager.playDirect(entity, compositeAnim);
|
||||||
|
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEAR / QUERY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all V2 animation layers from an entity and remove tracking.
|
||||||
|
* Stops both the context layer and the item layer.
|
||||||
|
*
|
||||||
|
* @param entity the entity to clear animations from
|
||||||
|
*/
|
||||||
|
public static void clearV2Animation(LivingEntity entity) {
|
||||||
|
if (entity == null) return;
|
||||||
|
activeStateKeys.remove(entity.getUUID());
|
||||||
|
BondageAnimationManager.stopContext(entity);
|
||||||
|
BondageAnimationManager.stopAnimation(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an entity has active V2 animation state.
|
||||||
|
*
|
||||||
|
* @param entity the entity to check
|
||||||
|
* @return true if the entity has an active V2 animation state key
|
||||||
|
*/
|
||||||
|
public static boolean hasActiveState(LivingEntity entity) {
|
||||||
|
return entity != null && activeStateKeys.containsKey(entity.getUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tracking for an entity (e.g., on logout/unload).
|
||||||
|
* Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that.
|
||||||
|
*
|
||||||
|
* @param entityId UUID of the entity to stop tracking
|
||||||
|
*/
|
||||||
|
public static void removeTracking(UUID entityId) {
|
||||||
|
activeStateKeys.remove(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACHE MANAGEMENT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached item animations and tracking state.
|
||||||
|
* Call this on resource reload (F3+T) to pick up changed GLB/JSON files.
|
||||||
|
*
|
||||||
|
* <p>Does NOT clear ContextAnimationFactory or ContextGlbRegistry here.
|
||||||
|
* Those are cleared in the reload listener AFTER ContextGlbRegistry.reload()
|
||||||
|
* to prevent the render thread from caching stale JSON fallbacks during
|
||||||
|
* the window between clear and repopulate.</p>
|
||||||
|
*/
|
||||||
|
public static void invalidateCache() {
|
||||||
|
itemAnimCache.clear();
|
||||||
|
activeStateKeys.clear();
|
||||||
|
failedLoadKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all state (cache + tracking). Called on world unload.
|
||||||
|
* Clears everything including context caches (no concurrent reload during unload).
|
||||||
|
*/
|
||||||
|
public static void clearAll() {
|
||||||
|
itemAnimCache.clear();
|
||||||
|
activeStateKeys.clear();
|
||||||
|
failedLoadKeys.clear();
|
||||||
|
com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear();
|
||||||
|
ContextAnimationFactory.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEGACY F9 DEBUG TOGGLE
|
||||||
|
|
||||||
|
private static boolean debugEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle debug mode via F9 key.
|
||||||
|
* When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player
|
||||||
|
* using STAND_IDLE context. When disabled, clears all V2 animation.
|
||||||
|
*/
|
||||||
|
public static void toggle() {
|
||||||
|
debugEnabled = !debugEnabled;
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Debug toggle: {}",
|
||||||
|
debugEnabled ? "ON" : "OFF"
|
||||||
|
);
|
||||||
|
|
||||||
|
AbstractClientPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return;
|
||||||
|
|
||||||
|
if (debugEnabled) {
|
||||||
|
ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"tiedup",
|
||||||
|
"models/gltf/v2/handcuffs/cuffs_prototype.glb"
|
||||||
|
);
|
||||||
|
Set<String> armParts = Set.of("rightArm", "leftArm");
|
||||||
|
RegionBoneMapper.BoneOwnership debugOwnership =
|
||||||
|
new RegionBoneMapper.BoneOwnership(armParts, Set.of());
|
||||||
|
applyV2Animation(
|
||||||
|
player,
|
||||||
|
modelLoc,
|
||||||
|
null,
|
||||||
|
AnimationContext.STAND_IDLE,
|
||||||
|
debugOwnership
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clearV2Animation(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether F9 debug mode is currently enabled.
|
||||||
|
*/
|
||||||
|
public static boolean isEnabled() {
|
||||||
|
return debugEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INTERNAL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build cache key for item-layer animations.
|
||||||
|
* Format: "animSource#contextName#sortedParts"
|
||||||
|
*/
|
||||||
|
private static String buildItemCacheKey(
|
||||||
|
ResourceLocation animSource,
|
||||||
|
AnimationContext context,
|
||||||
|
String partsKey
|
||||||
|
) {
|
||||||
|
return animSource + "#" + context.name() + "#" + partsKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a canonical, deterministic string from the owned parts set.
|
||||||
|
* Sorted alphabetically and joined by comma — guarantees no hash collisions.
|
||||||
|
*/
|
||||||
|
private static String canonicalPartsKey(Set<String> ownedParts) {
|
||||||
|
return String.join(",", new TreeSet<>(ownedParts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
|
||||||
|
*/
|
||||||
|
private static KeyframeAnimation.StateCollection getPartByName(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
return switch (name) {
|
||||||
|
case "head" -> builder.head;
|
||||||
|
case "body" -> builder.body;
|
||||||
|
case "rightArm" -> builder.rightArm;
|
||||||
|
case "leftArm" -> builder.leftArm;
|
||||||
|
case "rightLeg" -> builder.rightLeg;
|
||||||
|
case "leftLeg" -> builder.leftLeg;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java
Normal file
113
src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps glTF bone names to Minecraft HumanoidModel parts.
|
||||||
|
* Handles upper bones (full rotation) and lower bones (bend only).
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfBoneMapper {
|
||||||
|
|
||||||
|
/** Maps glTF bone name -> MC model part field name */
|
||||||
|
private static final Map<String, String> BONE_TO_PART = new HashMap<>();
|
||||||
|
|
||||||
|
/** Lower bones that represent bend (elbow/knee) */
|
||||||
|
private static final Set<String> LOWER_BONES = Set.of(
|
||||||
|
"leftLowerArm",
|
||||||
|
"rightLowerArm",
|
||||||
|
"leftLowerLeg",
|
||||||
|
"rightLowerLeg"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Maps lower bone name -> corresponding upper bone name */
|
||||||
|
private static final Map<String, String> LOWER_TO_UPPER = Map.of(
|
||||||
|
"leftLowerArm",
|
||||||
|
"leftUpperArm",
|
||||||
|
"rightLowerArm",
|
||||||
|
"rightUpperArm",
|
||||||
|
"leftLowerLeg",
|
||||||
|
"leftUpperLeg",
|
||||||
|
"rightLowerLeg",
|
||||||
|
"rightUpperLeg"
|
||||||
|
);
|
||||||
|
|
||||||
|
static {
|
||||||
|
BONE_TO_PART.put("body", "body");
|
||||||
|
BONE_TO_PART.put("torso", "body");
|
||||||
|
BONE_TO_PART.put("head", "head");
|
||||||
|
BONE_TO_PART.put("leftUpperArm", "leftArm");
|
||||||
|
BONE_TO_PART.put("leftLowerArm", "leftArm");
|
||||||
|
BONE_TO_PART.put("rightUpperArm", "rightArm");
|
||||||
|
BONE_TO_PART.put("rightLowerArm", "rightArm");
|
||||||
|
BONE_TO_PART.put("leftUpperLeg", "leftLeg");
|
||||||
|
BONE_TO_PART.put("leftLowerLeg", "leftLeg");
|
||||||
|
BONE_TO_PART.put("rightUpperLeg", "rightLeg");
|
||||||
|
BONE_TO_PART.put("rightLowerLeg", "rightLeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
private GltfBoneMapper() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ModelPart corresponding to a glTF bone name.
|
||||||
|
*
|
||||||
|
* @param model the HumanoidModel
|
||||||
|
* @param boneName glTF bone name
|
||||||
|
* @return the ModelPart, or null if not mapped
|
||||||
|
*/
|
||||||
|
public static ModelPart getModelPart(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
String boneName
|
||||||
|
) {
|
||||||
|
String partName = BONE_TO_PART.get(boneName);
|
||||||
|
if (partName == null) return null;
|
||||||
|
|
||||||
|
return switch (partName) {
|
||||||
|
case "body" -> model.body;
|
||||||
|
case "head" -> model.head;
|
||||||
|
case "leftArm" -> model.leftArm;
|
||||||
|
case "rightArm" -> model.rightArm;
|
||||||
|
case "leftLeg" -> model.leftLeg;
|
||||||
|
case "rightLeg" -> model.rightLeg;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this bone represents a lower segment (bend: elbow/knee).
|
||||||
|
*/
|
||||||
|
public static boolean isLowerBone(String boneName) {
|
||||||
|
return LOWER_BONES.contains(boneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the upper bone name for a given lower bone.
|
||||||
|
* Returns null if not a lower bone.
|
||||||
|
*/
|
||||||
|
public static String getUpperBoneFor(String lowerBoneName) {
|
||||||
|
return LOWER_TO_UPPER.get(lowerBoneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PlayerAnimator part name for a glTF bone.
|
||||||
|
* Both glTF and PlayerAnimator use "body" for the torso part.
|
||||||
|
*/
|
||||||
|
public static String getAnimPartName(String boneName) {
|
||||||
|
String partName = BONE_TO_PART.get(boneName);
|
||||||
|
if (partName == null) return null;
|
||||||
|
return partName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a bone name is known/mapped.
|
||||||
|
*/
|
||||||
|
public static boolean isKnownBone(String boneName) {
|
||||||
|
return BONE_TO_PART.containsKey(boneName);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main/java/com/tiedup/remake/client/gltf/GltfCache.java
Normal file
68
src/main/java/com/tiedup/remake/client/gltf/GltfCache.java
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.packs.resources.Resource;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-loading cache for parsed glTF data.
|
||||||
|
* Loads .glb files via Minecraft's ResourceManager on first access.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfCache {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
private static final Map<ResourceLocation, GltfData> CACHE =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private GltfCache() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parsed glTF data for a resource, loading it on first access.
|
||||||
|
*
|
||||||
|
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb")
|
||||||
|
* @return parsed GltfData, or null if loading failed
|
||||||
|
*/
|
||||||
|
public static GltfData get(ResourceLocation location) {
|
||||||
|
GltfData cached = CACHE.get(location);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Resource resource = Minecraft.getInstance()
|
||||||
|
.getResourceManager()
|
||||||
|
.getResource(location)
|
||||||
|
.orElse(null);
|
||||||
|
if (resource == null) {
|
||||||
|
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream is = resource.open()) {
|
||||||
|
GltfData data = GlbParser.parse(is, location.toString());
|
||||||
|
CACHE.put(location, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all cached data (call on resource reload). */
|
||||||
|
public static void clearCache() {
|
||||||
|
CACHE.clear();
|
||||||
|
LOGGER.info("[GltfPipeline] Cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize the cache (called during FMLClientSetupEvent). */
|
||||||
|
public static void init() {
|
||||||
|
LOGGER.info("[GltfPipeline] GltfCache initialized");
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
Normal file
165
src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||||
|
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
|
||||||
|
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
|
||||||
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
|
||||||
|
import net.minecraft.client.KeyMapping;
|
||||||
|
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
|
||||||
|
import net.minecraft.server.packs.resources.ResourceManager;
|
||||||
|
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||||
|
import net.minecraft.util.profiling.ProfilerFiller;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||||
|
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
||||||
|
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
|
||||||
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forge event registration for the glTF pipeline.
|
||||||
|
* Registers keybind (F9), render layers, and animation factory.
|
||||||
|
*/
|
||||||
|
public final class GltfClientSetup {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
private static final String KEY_CATEGORY = "key.categories.tiedup";
|
||||||
|
static final KeyMapping TOGGLE_KEY = new KeyMapping(
|
||||||
|
"key.tiedup.gltf_toggle",
|
||||||
|
InputConstants.Type.KEYSYM,
|
||||||
|
InputConstants.KEY_F9,
|
||||||
|
KEY_CATEGORY
|
||||||
|
);
|
||||||
|
|
||||||
|
private GltfClientSetup() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = "tiedup",
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.MOD,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public static class ModBusEvents {
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientSetup(FMLClientSetupEvent event) {
|
||||||
|
event.enqueueWork(() -> {
|
||||||
|
GltfCache.init();
|
||||||
|
GltfAnimationApplier.init();
|
||||||
|
LOGGER.info("[GltfPipeline] Client setup complete");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRegisterKeybindings(
|
||||||
|
RegisterKeyMappingsEvent event
|
||||||
|
) {
|
||||||
|
event.register(TOGGLE_KEY);
|
||||||
|
LOGGER.info("[GltfPipeline] Keybind registered: F9");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
|
||||||
|
// Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers
|
||||||
|
var defaultRenderer = event.getSkin("default");
|
||||||
|
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
|
||||||
|
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
|
||||||
|
playerRenderer.addLayer(
|
||||||
|
new V2BondageRenderLayer<>(playerRenderer)
|
||||||
|
);
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Render layers added to 'default' player renderer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add both layers to slim player renderer (Alex)
|
||||||
|
var slimRenderer = event.getSkin("slim");
|
||||||
|
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
|
||||||
|
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
|
||||||
|
playerRenderer.addLayer(
|
||||||
|
new V2BondageRenderLayer<>(playerRenderer)
|
||||||
|
);
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Render layers added to 'slim' player renderer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register resource reload listener to clear GLB caches on resource pack reload.
|
||||||
|
* This ensures re-exported GLB models are picked up without restarting the game.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRegisterReloadListeners(
|
||||||
|
RegisterClientReloadListenersEvent event
|
||||||
|
) {
|
||||||
|
event.registerReloadListener(
|
||||||
|
new SimplePreparableReloadListener<Void>() {
|
||||||
|
@Override
|
||||||
|
protected Void prepare(
|
||||||
|
ResourceManager resourceManager,
|
||||||
|
ProfilerFiller profiler
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void apply(
|
||||||
|
Void nothing,
|
||||||
|
ResourceManager resourceManager,
|
||||||
|
ProfilerFiller profiler
|
||||||
|
) {
|
||||||
|
GltfCache.clearCache();
|
||||||
|
GltfAnimationApplier.invalidateCache();
|
||||||
|
GltfMeshRenderer.clearRenderTypeCache();
|
||||||
|
// Reload context GLB animations from resource packs FIRST,
|
||||||
|
// then clear the factory cache so it rebuilds against the
|
||||||
|
// new GLB registry (prevents stale JSON fallback caching).
|
||||||
|
ContextGlbRegistry.reload(resourceManager);
|
||||||
|
ContextAnimationFactory.clearCache();
|
||||||
|
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Caches cleared on resource reload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
LOGGER.info("[GltfPipeline] Resource reload listener registered");
|
||||||
|
|
||||||
|
// Data-driven bondage item definitions (tiedup_items/*.json)
|
||||||
|
event.registerReloadListener(new DataDrivenItemReloadListener());
|
||||||
|
LOGGER.info(
|
||||||
|
"[GltfPipeline] Data-driven item reload listener registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FORGE bus event subscribers (ClientTickEvent for keybind toggle).
|
||||||
|
*/
|
||||||
|
@Mod.EventBusSubscriber(
|
||||||
|
modid = "tiedup",
|
||||||
|
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||||
|
value = Dist.CLIENT
|
||||||
|
)
|
||||||
|
public static class ForgeBusEvents {
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||||
|
if (event.phase != TickEvent.Phase.END) return;
|
||||||
|
|
||||||
|
while (TOGGLE_KEY.consumeClick()) {
|
||||||
|
GltfAnimationApplier.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/main/java/com/tiedup/remake/client/gltf/GltfData.java
Normal file
300
src/main/java/com/tiedup/remake/client/gltf/GltfData.java
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable container for parsed glTF/GLB data.
|
||||||
|
* Holds mesh geometry, skinning data, bone hierarchy, and optional animations.
|
||||||
|
* <p>
|
||||||
|
* Supports multiple named animations per GLB file. The "default" animation
|
||||||
|
* (first clip) is accessible via {@link #animation()} and {@link #rawGltfAnimation()}
|
||||||
|
* for backward compatibility. All animations are available via
|
||||||
|
* {@link #namedAnimations()}.
|
||||||
|
*/
|
||||||
|
public final class GltfData {
|
||||||
|
|
||||||
|
// -- Mesh geometry (flattened arrays) --
|
||||||
|
private final float[] positions; // VEC3, length = vertexCount * 3
|
||||||
|
private final float[] normals; // VEC3, length = vertexCount * 3
|
||||||
|
private final float[] texCoords; // VEC2, length = vertexCount * 2
|
||||||
|
private final int[] indices; // triangle indices
|
||||||
|
|
||||||
|
// -- Skinning data (per-vertex, 4 influences) --
|
||||||
|
private final int[] joints; // 4 joint indices per vertex, length = vertexCount * 4
|
||||||
|
private final float[] weights; // 4 weights per vertex, length = vertexCount * 4
|
||||||
|
|
||||||
|
// -- Bone hierarchy (MC-converted for skinning) --
|
||||||
|
private final String[] jointNames;
|
||||||
|
private final int[] parentJointIndices; // -1 for root
|
||||||
|
private final Matrix4f[] inverseBindMatrices;
|
||||||
|
private final Quaternionf[] restRotations;
|
||||||
|
private final Vector3f[] restTranslations;
|
||||||
|
|
||||||
|
// -- Raw glTF rotations (unconverted, for pose conversion) --
|
||||||
|
private final Quaternionf[] rawGltfRestRotations;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final AnimationClip rawGltfAnimation;
|
||||||
|
|
||||||
|
// -- Optional animation clip (MC-converted for skinning) --
|
||||||
|
@Nullable
|
||||||
|
private final AnimationClip animation;
|
||||||
|
|
||||||
|
// -- Multiple named animations --
|
||||||
|
private final Map<String, AnimationClip> namedAnimations; // MC-converted
|
||||||
|
private final Map<String, AnimationClip> rawNamedAnimations; // raw glTF space
|
||||||
|
|
||||||
|
// -- Per-primitive material/tint info --
|
||||||
|
private final List<Primitive> primitives;
|
||||||
|
|
||||||
|
// -- Counts --
|
||||||
|
private final int vertexCount;
|
||||||
|
private final int jointCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full constructor with multiple named animations and per-primitive data.
|
||||||
|
*/
|
||||||
|
public GltfData(
|
||||||
|
float[] positions,
|
||||||
|
float[] normals,
|
||||||
|
float[] texCoords,
|
||||||
|
int[] indices,
|
||||||
|
int[] joints,
|
||||||
|
float[] weights,
|
||||||
|
String[] jointNames,
|
||||||
|
int[] parentJointIndices,
|
||||||
|
Matrix4f[] inverseBindMatrices,
|
||||||
|
Quaternionf[] restRotations,
|
||||||
|
Vector3f[] restTranslations,
|
||||||
|
Quaternionf[] rawGltfRestRotations,
|
||||||
|
@Nullable AnimationClip rawGltfAnimation,
|
||||||
|
@Nullable AnimationClip animation,
|
||||||
|
Map<String, AnimationClip> namedAnimations,
|
||||||
|
Map<String, AnimationClip> rawNamedAnimations,
|
||||||
|
List<Primitive> primitives,
|
||||||
|
int vertexCount,
|
||||||
|
int jointCount
|
||||||
|
) {
|
||||||
|
this.positions = positions;
|
||||||
|
this.normals = normals;
|
||||||
|
this.texCoords = texCoords;
|
||||||
|
this.indices = indices;
|
||||||
|
this.joints = joints;
|
||||||
|
this.weights = weights;
|
||||||
|
this.jointNames = jointNames;
|
||||||
|
this.parentJointIndices = parentJointIndices;
|
||||||
|
this.inverseBindMatrices = inverseBindMatrices;
|
||||||
|
this.restRotations = restRotations;
|
||||||
|
this.restTranslations = restTranslations;
|
||||||
|
this.rawGltfRestRotations = rawGltfRestRotations;
|
||||||
|
this.rawGltfAnimation = rawGltfAnimation;
|
||||||
|
this.animation = animation;
|
||||||
|
this.namedAnimations = Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<>(namedAnimations)
|
||||||
|
);
|
||||||
|
this.rawNamedAnimations = Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<>(rawNamedAnimations)
|
||||||
|
);
|
||||||
|
this.primitives = List.copyOf(primitives);
|
||||||
|
this.vertexCount = vertexCount;
|
||||||
|
this.jointCount = jointCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy constructor for backward compatibility (single animation only).
|
||||||
|
*/
|
||||||
|
public GltfData(
|
||||||
|
float[] positions,
|
||||||
|
float[] normals,
|
||||||
|
float[] texCoords,
|
||||||
|
int[] indices,
|
||||||
|
int[] joints,
|
||||||
|
float[] weights,
|
||||||
|
String[] jointNames,
|
||||||
|
int[] parentJointIndices,
|
||||||
|
Matrix4f[] inverseBindMatrices,
|
||||||
|
Quaternionf[] restRotations,
|
||||||
|
Vector3f[] restTranslations,
|
||||||
|
Quaternionf[] rawGltfRestRotations,
|
||||||
|
@Nullable AnimationClip rawGltfAnimation,
|
||||||
|
@Nullable AnimationClip animation,
|
||||||
|
int vertexCount,
|
||||||
|
int jointCount
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
texCoords,
|
||||||
|
indices,
|
||||||
|
joints,
|
||||||
|
weights,
|
||||||
|
jointNames,
|
||||||
|
parentJointIndices,
|
||||||
|
inverseBindMatrices,
|
||||||
|
restRotations,
|
||||||
|
restTranslations,
|
||||||
|
rawGltfRestRotations,
|
||||||
|
rawGltfAnimation,
|
||||||
|
animation,
|
||||||
|
new LinkedHashMap<>(),
|
||||||
|
new LinkedHashMap<>(),
|
||||||
|
List.of(new Primitive(indices, null, false, null)),
|
||||||
|
vertexCount,
|
||||||
|
jointCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] positions() {
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] normals() {
|
||||||
|
return normals;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] texCoords() {
|
||||||
|
return texCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] indices() {
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] joints() {
|
||||||
|
return joints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] weights() {
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String[] jointNames() {
|
||||||
|
return jointNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] parentJointIndices() {
|
||||||
|
return parentJointIndices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Matrix4f[] inverseBindMatrices() {
|
||||||
|
return inverseBindMatrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Quaternionf[] restRotations() {
|
||||||
|
return restRotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3f[] restTranslations() {
|
||||||
|
return restTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Quaternionf[] rawGltfRestRotations() {
|
||||||
|
return rawGltfRestRotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public AnimationClip rawGltfAnimation() {
|
||||||
|
return rawGltfAnimation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public AnimationClip animation() {
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int vertexCount() {
|
||||||
|
return vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int jointCount() {
|
||||||
|
return jointCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */
|
||||||
|
public List<Primitive> primitives() {
|
||||||
|
return primitives;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */
|
||||||
|
public Map<String, AnimationClip> namedAnimations() {
|
||||||
|
return namedAnimations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a specific named animation in MC-converted space, or null if not found. */
|
||||||
|
@Nullable
|
||||||
|
public AnimationClip getAnimation(String name) {
|
||||||
|
return namedAnimations.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a specific named animation in raw glTF space, or null if not found. */
|
||||||
|
@Nullable
|
||||||
|
public AnimationClip getRawAnimation(String name) {
|
||||||
|
return rawNamedAnimations.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation clip: per-bone timestamps, quaternion rotations, and optional translations.
|
||||||
|
*/
|
||||||
|
public static final class AnimationClip {
|
||||||
|
|
||||||
|
private final float[] timestamps; // shared timestamps
|
||||||
|
private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim
|
||||||
|
|
||||||
|
private final int frameCount;
|
||||||
|
|
||||||
|
public AnimationClip(
|
||||||
|
float[] timestamps,
|
||||||
|
Quaternionf[][] rotations,
|
||||||
|
@Nullable Vector3f[][] translations,
|
||||||
|
int frameCount
|
||||||
|
) {
|
||||||
|
this.timestamps = timestamps;
|
||||||
|
this.rotations = rotations;
|
||||||
|
this.translations = translations;
|
||||||
|
this.frameCount = frameCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] timestamps() {
|
||||||
|
return timestamps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Quaternionf[][] rotations() {
|
||||||
|
return rotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Vector3f[][] translations() {
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int frameCount() {
|
||||||
|
return frameCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-primitive metadata parsed from the glTF mesh.
|
||||||
|
* Each primitive corresponds to a material assignment in Blender.
|
||||||
|
*
|
||||||
|
* @param indices triangle indices for this primitive (already offset to the unified vertex buffer)
|
||||||
|
* @param materialName the glTF material name, or null if unassigned
|
||||||
|
* @param tintable true if the material name starts with "tintable_"
|
||||||
|
* @param tintChannel the tint channel key (e.g. "tintable_0"), or null if not tintable
|
||||||
|
*/
|
||||||
|
public record Primitive(
|
||||||
|
int[] indices,
|
||||||
|
@Nullable String materialName,
|
||||||
|
boolean tintable,
|
||||||
|
@Nullable String tintChannel
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import dev.kosmx.playerAnim.core.util.Pair;
|
||||||
|
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
||||||
|
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
|
||||||
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib
|
||||||
|
* have applied all rotations for the current frame) and produces joint matrices
|
||||||
|
* compatible with {@link GltfSkinningEngine#skinVertex}.
|
||||||
|
* <p>
|
||||||
|
* KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent
|
||||||
|
* DELTA rotations (difference from rest pose) expressed in the MC model-def frame.
|
||||||
|
* GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX.
|
||||||
|
* <p>
|
||||||
|
* To reconstruct the correct LOCAL rotation for the glTF hierarchy:
|
||||||
|
* <pre>
|
||||||
|
* delta = rotationZYX(zRot, yRot, xRot) // MC-frame delta from ModelPart
|
||||||
|
* localRot = delta * restQ_mc // delta applied on top of local rest
|
||||||
|
* </pre>
|
||||||
|
* No de-parenting is needed because both delta and restQ_mc are already in the
|
||||||
|
* parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism,
|
||||||
|
* so frame relationships are preserved through the conversion.
|
||||||
|
* <p>
|
||||||
|
* For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation
|
||||||
|
* directly from GltfData.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfLiveBoneReader {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
private GltfLiveBoneReader() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
|
||||||
|
* <p>
|
||||||
|
* For upper bones: reconstructs the MC-frame delta from ModelPart euler angles,
|
||||||
|
* then composes with the MC-converted rest rotation to get the local rotation.
|
||||||
|
* For lower bones: reads bend values from the entity's AnimationApplier and
|
||||||
|
* composes the bend delta with the local rest rotation.
|
||||||
|
* For non-animated bones: uses rest rotation from GltfData directly.
|
||||||
|
* <p>
|
||||||
|
* The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices}
|
||||||
|
* when the player is in the rest pose (no animation active).
|
||||||
|
*
|
||||||
|
* @param model the HumanoidModel after PlayerAnimator has applied rotations
|
||||||
|
* @param data parsed glTF data (MC-converted)
|
||||||
|
* @param entity the living entity being rendered
|
||||||
|
* @return array of joint matrices ready for skinning, or null on failure
|
||||||
|
*/
|
||||||
|
public static Matrix4f[] computeJointMatricesFromModel(
|
||||||
|
HumanoidModel<?> model,
|
||||||
|
GltfData data,
|
||||||
|
LivingEntity entity
|
||||||
|
) {
|
||||||
|
if (model == null || data == null || entity == null) return null;
|
||||||
|
|
||||||
|
int jointCount = data.jointCount();
|
||||||
|
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||||
|
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||||
|
|
||||||
|
int[] parents = data.parentJointIndices();
|
||||||
|
String[] jointNames = data.jointNames();
|
||||||
|
Quaternionf[] restRotations = data.restRotations();
|
||||||
|
Vector3f[] restTranslations = data.restTranslations();
|
||||||
|
|
||||||
|
// Get the AnimationApplier for bend values (may be null)
|
||||||
|
AnimationApplier emote = getAnimationApplier(entity);
|
||||||
|
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
String boneName = jointNames[j];
|
||||||
|
Quaternionf localRot;
|
||||||
|
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
// --- Lower bone: reconstruct from bend values ---
|
||||||
|
localRot = computeLowerBoneLocalRotation(
|
||||||
|
boneName,
|
||||||
|
j,
|
||||||
|
restRotations,
|
||||||
|
emote
|
||||||
|
);
|
||||||
|
} else if (hasUniqueModelPart(boneName)) {
|
||||||
|
// --- Upper bone with a unique ModelPart ---
|
||||||
|
ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
|
||||||
|
if (part != null) {
|
||||||
|
localRot = computeUpperBoneLocalRotation(
|
||||||
|
part,
|
||||||
|
j,
|
||||||
|
restRotations
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: use rest rotation
|
||||||
|
localRot = new Quaternionf(restRotations[j]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- Non-animated bone (root, torso, etc.): use rest rotation ---
|
||||||
|
localRot = new Quaternionf(restRotations[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build local transform: translate(restTranslation) * rotate(localRot)
|
||||||
|
Matrix4f local = new Matrix4f();
|
||||||
|
local.translate(restTranslations[j]);
|
||||||
|
local.rotate(localRot);
|
||||||
|
|
||||||
|
// Compose with parent to get world transform
|
||||||
|
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||||
|
worldTransforms[j] = new Matrix4f(
|
||||||
|
worldTransforms[parents[j]]
|
||||||
|
).mul(local);
|
||||||
|
} else {
|
||||||
|
worldTransforms[j] = new Matrix4f(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||||
|
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||||
|
data.inverseBindMatrices()[j]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jointMatrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute local rotation for an upper bone that has a unique ModelPart.
|
||||||
|
* <p>
|
||||||
|
* ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed
|
||||||
|
* as ZYX Euler angles in the MC model-def frame. These deltas were originally
|
||||||
|
* computed by GltfPoseConverter as parent-frame quantities.
|
||||||
|
* <p>
|
||||||
|
* The local rotation for the glTF hierarchy is simply:
|
||||||
|
* <pre>
|
||||||
|
* delta = rotationZYX(zRot, yRot, xRot)
|
||||||
|
* localRot = delta * restQ_mc
|
||||||
|
* </pre>
|
||||||
|
* No de-parenting is needed: both delta and restQ_mc are already in the parent's
|
||||||
|
* frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving
|
||||||
|
* the frame relationship.
|
||||||
|
*/
|
||||||
|
private static Quaternionf computeUpperBoneLocalRotation(
|
||||||
|
ModelPart part,
|
||||||
|
int jointIndex,
|
||||||
|
Quaternionf[] restRotations
|
||||||
|
) {
|
||||||
|
// Reconstruct the MC-frame delta from ModelPart euler angles.
|
||||||
|
Quaternionf delta = new Quaternionf().rotationZYX(
|
||||||
|
part.zRot,
|
||||||
|
part.yRot,
|
||||||
|
part.xRot
|
||||||
|
);
|
||||||
|
// Local rotation = delta applied on top of the local rest rotation.
|
||||||
|
return new Quaternionf(delta).mul(restRotations[jointIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute local rotation for a lower bone (elbow/knee) from bend values.
|
||||||
|
* <p>
|
||||||
|
* Bend values are read from the entity's AnimationApplier. The bend delta is
|
||||||
|
* reconstructed as a quaternion rotation around the bend axis, then composed
|
||||||
|
* with the local rest rotation:
|
||||||
|
* <pre>
|
||||||
|
* bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
|
||||||
|
* localRot = bendQuat * restQ_mc
|
||||||
|
* </pre>
|
||||||
|
* No de-parenting needed — same reasoning as upper bones.
|
||||||
|
*/
|
||||||
|
private static Quaternionf computeLowerBoneLocalRotation(
|
||||||
|
String boneName,
|
||||||
|
int jointIndex,
|
||||||
|
Quaternionf[] restRotations,
|
||||||
|
AnimationApplier emote
|
||||||
|
) {
|
||||||
|
if (emote != null) {
|
||||||
|
// Get the MC part name for the upper bone of this lower bone
|
||||||
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||||
|
String animPartName = (upperBone != null)
|
||||||
|
? GltfBoneMapper.getAnimPartName(upperBone)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (animPartName != null) {
|
||||||
|
Pair<Float, Float> bend = emote.getBend(animPartName);
|
||||||
|
if (bend != null) {
|
||||||
|
float bendAxis = bend.getLeft();
|
||||||
|
float bendValue = bend.getRight();
|
||||||
|
|
||||||
|
// Reconstruct bend as quaternion (this is the delta)
|
||||||
|
float ax = (float) Math.cos(bendAxis);
|
||||||
|
float az = (float) Math.sin(bendAxis);
|
||||||
|
float halfAngle = bendValue * 0.5f;
|
||||||
|
float s = (float) Math.sin(halfAngle);
|
||||||
|
Quaternionf bendQuat = new Quaternionf(
|
||||||
|
ax * s,
|
||||||
|
0,
|
||||||
|
az * s,
|
||||||
|
(float) Math.cos(halfAngle)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local rotation = bend delta applied on top of local rest rotation
|
||||||
|
return new Quaternionf(bendQuat).mul(
|
||||||
|
restRotations[jointIndex]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bend data or no AnimationApplier — use rest rotation (identity delta)
|
||||||
|
return new Quaternionf(restRotations[jointIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a bone name corresponds to a bone that has its OWN unique ModelPart
|
||||||
|
* (not just a mapping — it must be the PRIMARY bone for that ModelPart).
|
||||||
|
* <p>
|
||||||
|
* "torso" maps to model.body but "body" is the primary bone for it.
|
||||||
|
* Lower bones share a ModelPart with their upper bone.
|
||||||
|
* Unknown bones (e.g., "PlayerArmature") have no ModelPart at all.
|
||||||
|
*/
|
||||||
|
private static boolean hasUniqueModelPart(String boneName) {
|
||||||
|
// Bones that should read their rotation from the live HumanoidModel.
|
||||||
|
//
|
||||||
|
// NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT —
|
||||||
|
// body, arms, legs, head are all siblings with ABSOLUTE rotations.
|
||||||
|
// But the GLB skeleton is HIERARCHICAL (body → torso → arms).
|
||||||
|
// If we read body's live rotation (e.g., attack swing yRot), it propagates
|
||||||
|
// to arms/head through the hierarchy, but MC's flat model does NOT do this.
|
||||||
|
// Result: cuffs mesh rotates with body during attack while arms stay put.
|
||||||
|
//
|
||||||
|
// Body rotation effects that matter (sneak lean, sitting) are handled by
|
||||||
|
// LivingEntityRenderer's PoseStack transform, which applies to the entire
|
||||||
|
// mesh uniformly. No need to read body rotation into joint matrices.
|
||||||
|
return switch (boneName) {
|
||||||
|
case "head" -> true;
|
||||||
|
case "leftUpperArm" -> true;
|
||||||
|
case "rightUpperArm" -> true;
|
||||||
|
case "leftUpperLeg" -> true;
|
||||||
|
case "rightUpperLeg" -> true;
|
||||||
|
default -> false; // body, torso, lower bones, unknown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AnimationApplier from an entity, if available.
|
||||||
|
* Works for both players (via mixin) and NPCs implementing IAnimatedPlayer.
|
||||||
|
*/
|
||||||
|
private static AnimationApplier getAnimationApplier(LivingEntity entity) {
|
||||||
|
if (entity instanceof IAnimatedPlayer animated) {
|
||||||
|
try {
|
||||||
|
return animated.playerAnimator_getAnimation();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Could not get AnimationApplier for {}: {}",
|
||||||
|
entity.getClass().getSimpleName(),
|
||||||
|
e.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.RenderStateShard;
|
||||||
|
import net.minecraft.client.renderer.RenderType;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.joml.Matrix3f;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Vector4f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits CPU-skinned glTF mesh vertices to Minecraft's rendering pipeline.
|
||||||
|
* Uses TRIANGLES mode RenderType (same pattern as ObjModelRenderer).
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfMeshRenderer extends RenderStateShard {
|
||||||
|
|
||||||
|
private static final ResourceLocation WHITE_TEXTURE =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"tiedup",
|
||||||
|
"models/obj/shared/white.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Cached default RenderType (white texture). Created once, reused every frame. */
|
||||||
|
private static RenderType cachedDefaultRenderType;
|
||||||
|
|
||||||
|
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
|
||||||
|
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private GltfMeshRenderer() {
|
||||||
|
super("tiedup_gltf_renderer", () -> {}, () -> {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default TRIANGLES-mode RenderType (white texture), creating it once if needed.
|
||||||
|
*/
|
||||||
|
private static RenderType getDefaultRenderType() {
|
||||||
|
if (cachedDefaultRenderType == null) {
|
||||||
|
cachedDefaultRenderType = createTriangleRenderType(WHITE_TEXTURE);
|
||||||
|
}
|
||||||
|
return cachedDefaultRenderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public accessor for the default RenderType (white texture).
|
||||||
|
* Used by external renderers that need the same RenderType for tinted rendering.
|
||||||
|
*/
|
||||||
|
public static RenderType getRenderTypeForDefaultTexture() {
|
||||||
|
return getDefaultRenderType();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a RenderType for a specific texture, caching it for reuse.
|
||||||
|
*
|
||||||
|
* @param texture the texture ResourceLocation
|
||||||
|
* @return the cached or newly created RenderType
|
||||||
|
*/
|
||||||
|
private static RenderType getRenderTypeForTexture(
|
||||||
|
ResourceLocation texture
|
||||||
|
) {
|
||||||
|
return RENDER_TYPE_CACHE.computeIfAbsent(
|
||||||
|
texture,
|
||||||
|
GltfMeshRenderer::createTriangleRenderType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture.
|
||||||
|
*/
|
||||||
|
private static RenderType createTriangleRenderType(
|
||||||
|
ResourceLocation texture
|
||||||
|
) {
|
||||||
|
RenderType.CompositeState state = RenderType.CompositeState.builder()
|
||||||
|
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
|
||||||
|
.setTextureState(
|
||||||
|
new RenderStateShard.TextureStateShard(texture, false, false)
|
||||||
|
)
|
||||||
|
.setTransparencyState(NO_TRANSPARENCY)
|
||||||
|
.setCullState(NO_CULL)
|
||||||
|
.setLightmapState(LIGHTMAP)
|
||||||
|
.setOverlayState(OVERLAY)
|
||||||
|
.createCompositeState(true);
|
||||||
|
|
||||||
|
return RenderType.create(
|
||||||
|
"tiedup_gltf_triangles",
|
||||||
|
DefaultVertexFormat.NEW_ENTITY,
|
||||||
|
VertexFormat.Mode.TRIANGLES,
|
||||||
|
256 * 1024,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached RenderTypes. Call on resource reload so that re-exported
|
||||||
|
* textures are picked up without restarting the game.
|
||||||
|
*/
|
||||||
|
public static void clearRenderTypeCache() {
|
||||||
|
cachedDefaultRenderType = null;
|
||||||
|
RENDER_TYPE_CACHE.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a skinned glTF mesh using the default white texture.
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param jointMatrices computed joint matrices from skinning engine
|
||||||
|
* @param poseStack current pose stack
|
||||||
|
* @param buffer multi-buffer source
|
||||||
|
* @param packedLight packed light value
|
||||||
|
* @param packedOverlay packed overlay value
|
||||||
|
*/
|
||||||
|
public static void renderSkinned(
|
||||||
|
GltfData data,
|
||||||
|
Matrix4f[] jointMatrices,
|
||||||
|
PoseStack poseStack,
|
||||||
|
MultiBufferSource buffer,
|
||||||
|
int packedLight,
|
||||||
|
int packedOverlay
|
||||||
|
) {
|
||||||
|
renderSkinnedInternal(
|
||||||
|
data,
|
||||||
|
jointMatrices,
|
||||||
|
poseStack,
|
||||||
|
buffer,
|
||||||
|
packedLight,
|
||||||
|
packedOverlay,
|
||||||
|
getDefaultRenderType()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a skinned glTF mesh using a custom texture.
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param jointMatrices computed joint matrices from skinning engine
|
||||||
|
* @param poseStack current pose stack
|
||||||
|
* @param buffer multi-buffer source
|
||||||
|
* @param packedLight packed light value
|
||||||
|
* @param packedOverlay packed overlay value
|
||||||
|
* @param texture the texture to use for rendering
|
||||||
|
*/
|
||||||
|
public static void renderSkinned(
|
||||||
|
GltfData data,
|
||||||
|
Matrix4f[] jointMatrices,
|
||||||
|
PoseStack poseStack,
|
||||||
|
MultiBufferSource buffer,
|
||||||
|
int packedLight,
|
||||||
|
int packedOverlay,
|
||||||
|
ResourceLocation texture
|
||||||
|
) {
|
||||||
|
renderSkinnedInternal(
|
||||||
|
data,
|
||||||
|
jointMatrices,
|
||||||
|
poseStack,
|
||||||
|
buffer,
|
||||||
|
packedLight,
|
||||||
|
packedOverlay,
|
||||||
|
getRenderTypeForTexture(texture)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal rendering implementation shared by both overloads.
|
||||||
|
*/
|
||||||
|
private static void renderSkinnedInternal(
|
||||||
|
GltfData data,
|
||||||
|
Matrix4f[] jointMatrices,
|
||||||
|
PoseStack poseStack,
|
||||||
|
MultiBufferSource buffer,
|
||||||
|
int packedLight,
|
||||||
|
int packedOverlay,
|
||||||
|
RenderType renderType
|
||||||
|
) {
|
||||||
|
Matrix4f pose = poseStack.last().pose();
|
||||||
|
Matrix3f normalMat = poseStack.last().normal();
|
||||||
|
|
||||||
|
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||||
|
|
||||||
|
int[] indices = data.indices();
|
||||||
|
float[] texCoords = data.texCoords();
|
||||||
|
|
||||||
|
float[] outPos = new float[3];
|
||||||
|
float[] outNormal = new float[3];
|
||||||
|
|
||||||
|
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations
|
||||||
|
Vector4f tmpPos = new Vector4f();
|
||||||
|
Vector4f tmpNorm = new Vector4f();
|
||||||
|
|
||||||
|
for (int idx : indices) {
|
||||||
|
// Skin this vertex
|
||||||
|
GltfSkinningEngine.skinVertex(
|
||||||
|
data,
|
||||||
|
idx,
|
||||||
|
jointMatrices,
|
||||||
|
outPos,
|
||||||
|
outNormal,
|
||||||
|
tmpPos,
|
||||||
|
tmpNorm
|
||||||
|
);
|
||||||
|
|
||||||
|
// UV coordinates
|
||||||
|
float u = texCoords[idx * 2];
|
||||||
|
float v = texCoords[idx * 2 + 1];
|
||||||
|
|
||||||
|
vc
|
||||||
|
.vertex(pose, outPos[0], outPos[1], outPos[2])
|
||||||
|
.color(255, 255, 255, 255)
|
||||||
|
.uv(u, 1.0f - v)
|
||||||
|
.overlayCoords(packedOverlay)
|
||||||
|
.uv2(packedLight)
|
||||||
|
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
|
||||||
|
.endVertex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a skinned glTF mesh with per-primitive tint colors.
|
||||||
|
*
|
||||||
|
* <p>Each primitive in the mesh is checked against the tintColors map.
|
||||||
|
* If a primitive is tintable and its channel is present in the map,
|
||||||
|
* the corresponding RGB color is applied as vertex color (multiplied
|
||||||
|
* against the texture by the {@code rendertype_entity_cutout_no_cull} shader).
|
||||||
|
* Non-tintable primitives render with white (no tint).</p>
|
||||||
|
*
|
||||||
|
* <p>This is a single VertexConsumer stream — all primitives share the
|
||||||
|
* same RenderType and draw call, only the vertex color differs per range.</p>
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data (must have primitives)
|
||||||
|
* @param jointMatrices computed joint matrices from skinning engine
|
||||||
|
* @param poseStack current pose stack
|
||||||
|
* @param buffer multi-buffer source
|
||||||
|
* @param packedLight packed light value
|
||||||
|
* @param packedOverlay packed overlay value
|
||||||
|
* @param renderType the RenderType to use
|
||||||
|
* @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere
|
||||||
|
*/
|
||||||
|
public static void renderSkinnedTinted(
|
||||||
|
GltfData data,
|
||||||
|
Matrix4f[] jointMatrices,
|
||||||
|
PoseStack poseStack,
|
||||||
|
MultiBufferSource buffer,
|
||||||
|
int packedLight,
|
||||||
|
int packedOverlay,
|
||||||
|
RenderType renderType,
|
||||||
|
Map<String, Integer> tintColors
|
||||||
|
) {
|
||||||
|
Matrix4f pose = poseStack.last().pose();
|
||||||
|
Matrix3f normalMat = poseStack.last().normal();
|
||||||
|
|
||||||
|
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||||
|
float[] texCoords = data.texCoords();
|
||||||
|
|
||||||
|
float[] outPos = new float[3];
|
||||||
|
float[] outNormal = new float[3];
|
||||||
|
Vector4f tmpPos = new Vector4f();
|
||||||
|
Vector4f tmpNorm = new Vector4f();
|
||||||
|
|
||||||
|
List<GltfData.Primitive> primitives = data.primitives();
|
||||||
|
|
||||||
|
for (GltfData.Primitive prim : primitives) {
|
||||||
|
// Determine color for this primitive
|
||||||
|
int r = 255,
|
||||||
|
g = 255,
|
||||||
|
b = 255;
|
||||||
|
if (prim.tintable() && prim.tintChannel() != null) {
|
||||||
|
Integer colorInt = tintColors.get(prim.tintChannel());
|
||||||
|
if (colorInt != null) {
|
||||||
|
r = (colorInt >> 16) & 0xFF;
|
||||||
|
g = (colorInt >> 8) & 0xFF;
|
||||||
|
b = colorInt & 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int idx : prim.indices()) {
|
||||||
|
GltfSkinningEngine.skinVertex(
|
||||||
|
data,
|
||||||
|
idx,
|
||||||
|
jointMatrices,
|
||||||
|
outPos,
|
||||||
|
outNormal,
|
||||||
|
tmpPos,
|
||||||
|
tmpNorm
|
||||||
|
);
|
||||||
|
|
||||||
|
float u = texCoords[idx * 2];
|
||||||
|
float v = texCoords[idx * 2 + 1];
|
||||||
|
|
||||||
|
vc
|
||||||
|
.vertex(pose, outPos[0], outPos[1], outPos[2])
|
||||||
|
.color(r, g, b, 255)
|
||||||
|
.uv(u, 1.0f - v)
|
||||||
|
.overlayCoords(packedOverlay)
|
||||||
|
.uv2(packedLight)
|
||||||
|
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
|
||||||
|
.endVertex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import dev.kosmx.playerAnim.core.data.AnimationFormat;
|
||||||
|
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||||
|
import dev.kosmx.playerAnim.core.util.Ease;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation.
|
||||||
|
* <p>
|
||||||
|
* Data is expected to be already in MC coordinate space (converted by GlbParser).
|
||||||
|
* For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll).
|
||||||
|
* For lower bones: extracts bend angle from delta quaternion.
|
||||||
|
* <p>
|
||||||
|
* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376),
|
||||||
|
* so no angle scaling is needed. If the pivots don't match, fix the Blender model.
|
||||||
|
* <p>
|
||||||
|
* Produces a static looping pose (beginTick=0, endTick=1, looped).
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfPoseConverter {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
private GltfPoseConverter() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
|
||||||
|
* Uses the default (first) animation clip.
|
||||||
|
* GltfData must already be in MC coordinate space.
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data (in MC space)
|
||||||
|
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
||||||
|
*/
|
||||||
|
public static KeyframeAnimation convert(GltfData data) {
|
||||||
|
return convertClip(data, data.rawGltfAnimation(), "gltf_pose");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a specific named animation from GltfData to a KeyframeAnimation.
|
||||||
|
* Falls back to the default animation if the name is not found.
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data (in MC space)
|
||||||
|
* @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
|
||||||
|
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
||||||
|
*/
|
||||||
|
public static KeyframeAnimation convert(
|
||||||
|
GltfData data,
|
||||||
|
String animationName
|
||||||
|
) {
|
||||||
|
GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
|
||||||
|
if (rawClip == null) {
|
||||||
|
LOGGER.warn(
|
||||||
|
"[GltfPipeline] Animation '{}' not found, falling back to default",
|
||||||
|
animationName
|
||||||
|
);
|
||||||
|
return convert(data);
|
||||||
|
}
|
||||||
|
return convertClip(data, rawClip, "gltf_" + animationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a GLB animation with selective part enabling and free-bone support.
|
||||||
|
*
|
||||||
|
* <p>Owned parts are always enabled in the output animation. Free parts (in
|
||||||
|
* {@code enabledParts} but not in {@code ownedParts}) are only enabled if the
|
||||||
|
* GLB contains actual keyframe data for them. Parts not in {@code enabledParts}
|
||||||
|
* at all are always disabled (pass through to lower layers).</p>
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data (in MC space)
|
||||||
|
* @param animationName animation name in GLB, or null for default
|
||||||
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||||
|
* @param enabledParts parts the item may animate (owned + free); free parts
|
||||||
|
* are only enabled if the GLB has keyframes for them
|
||||||
|
* @return KeyframeAnimation with selective parts active
|
||||||
|
*/
|
||||||
|
public static KeyframeAnimation convertSelective(
|
||||||
|
GltfData data,
|
||||||
|
@Nullable String animationName,
|
||||||
|
Set<String> ownedParts,
|
||||||
|
Set<String> enabledParts
|
||||||
|
) {
|
||||||
|
GltfData.AnimationClip rawClip;
|
||||||
|
String animName;
|
||||||
|
if (animationName != null) {
|
||||||
|
rawClip = data.getRawAnimation(animationName);
|
||||||
|
animName = "gltf_" + animationName;
|
||||||
|
} else {
|
||||||
|
rawClip = null;
|
||||||
|
animName = "gltf_pose";
|
||||||
|
}
|
||||||
|
if (rawClip == null) {
|
||||||
|
rawClip = data.rawGltfAnimation();
|
||||||
|
}
|
||||||
|
return convertClipSelective(
|
||||||
|
data,
|
||||||
|
rawClip,
|
||||||
|
animName,
|
||||||
|
ownedParts,
|
||||||
|
enabledParts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: convert a specific raw animation clip with selective part enabling
|
||||||
|
* and free-bone support.
|
||||||
|
*
|
||||||
|
* <p>Tracks which PlayerAnimator parts received actual keyframe data from the GLB.
|
||||||
|
* A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}.
|
||||||
|
* This information is used by {@link #enableSelectiveParts} to decide whether
|
||||||
|
* free parts should be enabled.</p>
|
||||||
|
*
|
||||||
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||||
|
* @param enabledParts parts the item may animate (owned + free)
|
||||||
|
*/
|
||||||
|
private static KeyframeAnimation convertClipSelective(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip rawClip,
|
||||||
|
String animName,
|
||||||
|
Set<String> ownedParts,
|
||||||
|
Set<String> enabledParts
|
||||||
|
) {
|
||||||
|
KeyframeAnimation.AnimationBuilder builder =
|
||||||
|
new KeyframeAnimation.AnimationBuilder(
|
||||||
|
AnimationFormat.JSON_EMOTECRAFT
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.beginTick = 0;
|
||||||
|
builder.endTick = 1;
|
||||||
|
builder.stopTick = 1;
|
||||||
|
builder.isLooped = true;
|
||||||
|
builder.returnTick = 0;
|
||||||
|
builder.name = animName;
|
||||||
|
|
||||||
|
String[] jointNames = data.jointNames();
|
||||||
|
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||||
|
|
||||||
|
// Track which PlayerAnimator part names received actual animation data
|
||||||
|
Set<String> partsWithKeyframes = new HashSet<>();
|
||||||
|
|
||||||
|
for (int j = 0; j < data.jointCount(); j++) {
|
||||||
|
String boneName = jointNames[j];
|
||||||
|
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||||
|
|
||||||
|
// Check if this joint has explicit animation data (not just rest pose fallback).
|
||||||
|
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
|
||||||
|
boolean hasExplicitAnim =
|
||||||
|
rawClip != null &&
|
||||||
|
((j < rawClip.rotations().length &&
|
||||||
|
rawClip.rotations()[j] != null) ||
|
||||||
|
(rawClip.translations() != null &&
|
||||||
|
j < rawClip.translations().length &&
|
||||||
|
rawClip.translations()[j] != null));
|
||||||
|
|
||||||
|
Quaternionf animQ = getRawAnimQuaternion(
|
||||||
|
rawClip,
|
||||||
|
rawRestRotations,
|
||||||
|
j
|
||||||
|
);
|
||||||
|
Quaternionf restQ = rawRestRotations[j];
|
||||||
|
|
||||||
|
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||||
|
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||||
|
|
||||||
|
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
|
||||||
|
Quaternionf deltaParent = new Quaternionf(restQ)
|
||||||
|
.mul(deltaLocal)
|
||||||
|
.mul(new Quaternionf(restQ).invert());
|
||||||
|
|
||||||
|
// Convert from glTF parent frame to MC model-def frame.
|
||||||
|
// 180deg rotation around Z (X and Y differ): negate qx and qy.
|
||||||
|
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||||
|
deltaQ.x = -deltaQ.x;
|
||||||
|
deltaQ.y = -deltaQ.y;
|
||||||
|
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
convertLowerBone(builder, boneName, deltaQ);
|
||||||
|
} else {
|
||||||
|
convertUpperBone(builder, boneName, deltaQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record which PlayerAnimator part received data
|
||||||
|
if (hasExplicitAnim) {
|
||||||
|
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||||
|
if (animPart != null) {
|
||||||
|
partsWithKeyframes.add(animPart);
|
||||||
|
}
|
||||||
|
// For lower bones, the keyframe data goes to the upper bone's part
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||||
|
if (upperBone != null) {
|
||||||
|
String upperPart = GltfBoneMapper.getAnimPartName(
|
||||||
|
upperBone
|
||||||
|
);
|
||||||
|
if (upperPart != null) {
|
||||||
|
partsWithKeyframes.add(upperPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selective: enable owned parts always, free parts only if they have keyframes
|
||||||
|
enableSelectiveParts(
|
||||||
|
builder,
|
||||||
|
ownedParts,
|
||||||
|
enabledParts,
|
||||||
|
partsWithKeyframes
|
||||||
|
);
|
||||||
|
|
||||||
|
KeyframeAnimation anim = builder.build();
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
|
||||||
|
animName,
|
||||||
|
ownedParts,
|
||||||
|
enabledParts,
|
||||||
|
partsWithKeyframes
|
||||||
|
);
|
||||||
|
return anim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
|
||||||
|
*
|
||||||
|
* <p>Only writes keyframes for bones that map to a part in {@code ownedParts}.
|
||||||
|
* Other bones are skipped entirely. This allows multiple items to contribute
|
||||||
|
* to the same animation builder without overwriting each other's keyframes.</p>
|
||||||
|
*
|
||||||
|
* @param builder the shared animation builder to add keyframes to
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param rawClip the raw animation clip, or null for rest pose
|
||||||
|
* @param ownedParts parts this item exclusively owns (only these get keyframes)
|
||||||
|
* @return set of part names that received actual keyframe data from the GLB
|
||||||
|
*/
|
||||||
|
public static Set<String> addBonesToBuilder(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
GltfData data,
|
||||||
|
@Nullable GltfData.AnimationClip rawClip,
|
||||||
|
Set<String> ownedParts
|
||||||
|
) {
|
||||||
|
String[] jointNames = data.jointNames();
|
||||||
|
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||||
|
Set<String> partsWithKeyframes = new HashSet<>();
|
||||||
|
|
||||||
|
for (int j = 0; j < data.jointCount(); j++) {
|
||||||
|
String boneName = jointNames[j];
|
||||||
|
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||||
|
|
||||||
|
// Only process bones that belong to this item's owned parts
|
||||||
|
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||||
|
if (animPart == null || !ownedParts.contains(animPart)) continue;
|
||||||
|
|
||||||
|
// For lower bones, check if the UPPER bone's part is owned
|
||||||
|
// (lower bone keyframes go to the upper bone's StateCollection)
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||||
|
if (upperBone != null) {
|
||||||
|
String upperPart = GltfBoneMapper.getAnimPartName(
|
||||||
|
upperBone
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
upperPart == null || !ownedParts.contains(upperPart)
|
||||||
|
) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasExplicitAnim =
|
||||||
|
rawClip != null &&
|
||||||
|
((j < rawClip.rotations().length &&
|
||||||
|
rawClip.rotations()[j] != null) ||
|
||||||
|
(rawClip.translations() != null &&
|
||||||
|
j < rawClip.translations().length &&
|
||||||
|
rawClip.translations()[j] != null));
|
||||||
|
|
||||||
|
Quaternionf animQ = getRawAnimQuaternion(
|
||||||
|
rawClip,
|
||||||
|
rawRestRotations,
|
||||||
|
j
|
||||||
|
);
|
||||||
|
Quaternionf restQ = rawRestRotations[j];
|
||||||
|
|
||||||
|
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||||
|
Quaternionf deltaParent = new Quaternionf(restQ)
|
||||||
|
.mul(deltaLocal)
|
||||||
|
.mul(new Quaternionf(restQ).invert());
|
||||||
|
|
||||||
|
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||||
|
deltaQ.x = -deltaQ.x;
|
||||||
|
deltaQ.y = -deltaQ.y;
|
||||||
|
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
convertLowerBone(builder, boneName, deltaQ);
|
||||||
|
} else {
|
||||||
|
convertUpperBone(builder, boneName, deltaQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitAnim) {
|
||||||
|
partsWithKeyframes.add(animPart);
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||||
|
if (upperBone != null) {
|
||||||
|
String upperPart = GltfBoneMapper.getAnimPartName(
|
||||||
|
upperBone
|
||||||
|
);
|
||||||
|
if (upperPart != null) partsWithKeyframes.add(
|
||||||
|
upperPart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return partsWithKeyframes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an animation clip using skeleton data from a separate source.
|
||||||
|
*
|
||||||
|
* <p>This is useful when the animation clip is stored separately from the
|
||||||
|
* skeleton (e.g., furniture seat animations where the Player_* armature's
|
||||||
|
* clips are parsed into a separate map from the skeleton GltfData).</p>
|
||||||
|
*
|
||||||
|
* <p>The resulting animation has all parts fully enabled. Callers should
|
||||||
|
* create a mutable copy and selectively disable parts as needed.</p>
|
||||||
|
*
|
||||||
|
* @param skeleton the GltfData providing rest pose, joint names, and joint count
|
||||||
|
* @param clip the raw animation clip (in glTF space) to convert
|
||||||
|
* @param animName debug name for the resulting animation
|
||||||
|
* @return a static looping KeyframeAnimation with all parts enabled
|
||||||
|
*/
|
||||||
|
public static KeyframeAnimation convertWithSkeleton(
|
||||||
|
GltfData skeleton,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
String animName
|
||||||
|
) {
|
||||||
|
return convertClip(skeleton, clip, animName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: convert a specific raw animation clip to a KeyframeAnimation.
|
||||||
|
*/
|
||||||
|
private static KeyframeAnimation convertClip(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip rawClip,
|
||||||
|
String animName
|
||||||
|
) {
|
||||||
|
KeyframeAnimation.AnimationBuilder builder =
|
||||||
|
new KeyframeAnimation.AnimationBuilder(
|
||||||
|
AnimationFormat.JSON_EMOTECRAFT
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.beginTick = 0;
|
||||||
|
builder.endTick = 1;
|
||||||
|
builder.stopTick = 1;
|
||||||
|
builder.isLooped = true;
|
||||||
|
builder.returnTick = 0;
|
||||||
|
builder.name = animName;
|
||||||
|
|
||||||
|
String[] jointNames = data.jointNames();
|
||||||
|
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||||
|
|
||||||
|
for (int j = 0; j < data.jointCount(); j++) {
|
||||||
|
String boneName = jointNames[j];
|
||||||
|
|
||||||
|
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||||
|
|
||||||
|
Quaternionf animQ = getRawAnimQuaternion(
|
||||||
|
rawClip,
|
||||||
|
rawRestRotations,
|
||||||
|
j
|
||||||
|
);
|
||||||
|
Quaternionf restQ = rawRestRotations[j];
|
||||||
|
|
||||||
|
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||||
|
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||||
|
|
||||||
|
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
|
||||||
|
// Simplifies algebraically to: animQ * inv(restQ)
|
||||||
|
Quaternionf deltaParent = new Quaternionf(restQ)
|
||||||
|
.mul(deltaLocal)
|
||||||
|
.mul(new Quaternionf(restQ).invert());
|
||||||
|
|
||||||
|
// Convert from glTF parent frame to MC model-def frame.
|
||||||
|
// 180° rotation around Z (X and Y differ): negate qx and qy.
|
||||||
|
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||||
|
deltaQ.x = -deltaQ.x;
|
||||||
|
deltaQ.y = -deltaQ.y;
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
String.format(
|
||||||
|
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
|
||||||
|
boneName,
|
||||||
|
restQ.x,
|
||||||
|
restQ.y,
|
||||||
|
restQ.z,
|
||||||
|
restQ.w,
|
||||||
|
animQ.x,
|
||||||
|
animQ.y,
|
||||||
|
animQ.z,
|
||||||
|
animQ.w,
|
||||||
|
deltaQ.x,
|
||||||
|
deltaQ.y,
|
||||||
|
deltaQ.z,
|
||||||
|
deltaQ.w
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||||
|
convertLowerBone(builder, boneName, deltaQ);
|
||||||
|
} else {
|
||||||
|
convertUpperBone(builder, boneName, deltaQ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.fullyEnableParts();
|
||||||
|
|
||||||
|
KeyframeAnimation anim = builder.build();
|
||||||
|
LOGGER.debug(
|
||||||
|
"[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation",
|
||||||
|
animName
|
||||||
|
);
|
||||||
|
return anim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw animation quaternion for a joint from a specific clip.
|
||||||
|
* Falls back to rest rotation if the clip is null or has no data for this joint.
|
||||||
|
*/
|
||||||
|
private static Quaternionf getRawAnimQuaternion(
|
||||||
|
GltfData.AnimationClip rawClip,
|
||||||
|
Quaternionf[] rawRestRotations,
|
||||||
|
int jointIndex
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
rawClip != null &&
|
||||||
|
jointIndex < rawClip.rotations().length &&
|
||||||
|
rawClip.rotations()[jointIndex] != null
|
||||||
|
) {
|
||||||
|
return rawClip.rotations()[jointIndex][0]; // first frame
|
||||||
|
}
|
||||||
|
return rawRestRotations[jointIndex]; // fallback to rest
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void convertUpperBone(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
String boneName,
|
||||||
|
Quaternionf deltaQ
|
||||||
|
) {
|
||||||
|
// Decompose delta quaternion to Euler ZYX
|
||||||
|
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
|
||||||
|
// (the "ZYX" refers to rotation ORDER, not storage order)
|
||||||
|
Vector3f euler = new Vector3f();
|
||||||
|
deltaQ.getEulerAnglesZYX(euler);
|
||||||
|
float pitch = euler.x; // X rotation (pitch)
|
||||||
|
float yaw = euler.y; // Y rotation (yaw)
|
||||||
|
float roll = euler.z; // Z rotation (roll)
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
String.format(
|
||||||
|
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
|
||||||
|
boneName,
|
||||||
|
Math.toDegrees(pitch),
|
||||||
|
Math.toDegrees(yaw),
|
||||||
|
Math.toDegrees(roll)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the StateCollection for this body part
|
||||||
|
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||||
|
if (animPart == null) return;
|
||||||
|
|
||||||
|
KeyframeAnimation.StateCollection part = getPartByName(
|
||||||
|
builder,
|
||||||
|
animPart
|
||||||
|
);
|
||||||
|
if (part == null) return;
|
||||||
|
|
||||||
|
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
|
||||||
|
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
|
||||||
|
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void convertLowerBone(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
String boneName,
|
||||||
|
Quaternionf deltaQ
|
||||||
|
) {
|
||||||
|
// Extract bend angle and axis from the delta quaternion
|
||||||
|
float angle =
|
||||||
|
2.0f * (float) Math.acos(Math.min(1.0, Math.abs(deltaQ.w)));
|
||||||
|
|
||||||
|
// Determine bend direction from axis
|
||||||
|
float bendDirection = 0.0f;
|
||||||
|
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
|
||||||
|
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign: if w is negative, the angle wraps
|
||||||
|
if (deltaQ.w < 0) {
|
||||||
|
angle = -angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
String.format(
|
||||||
|
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
|
||||||
|
boneName,
|
||||||
|
Math.toDegrees(angle),
|
||||||
|
Math.toDegrees(bendDirection)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply bend to the upper bone's StateCollection
|
||||||
|
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||||
|
if (upperBone == null) return;
|
||||||
|
|
||||||
|
String animPart = GltfBoneMapper.getAnimPartName(upperBone);
|
||||||
|
if (animPart == null) return;
|
||||||
|
|
||||||
|
KeyframeAnimation.StateCollection part = getPartByName(
|
||||||
|
builder,
|
||||||
|
animPart
|
||||||
|
);
|
||||||
|
if (part == null || !part.isBendable) return;
|
||||||
|
|
||||||
|
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
|
||||||
|
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyframeAnimation.StateCollection getPartByName(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
return switch (name) {
|
||||||
|
case "head" -> builder.head;
|
||||||
|
case "body" -> builder.body;
|
||||||
|
case "rightArm" -> builder.rightArm;
|
||||||
|
case "leftArm" -> builder.leftArm;
|
||||||
|
case "rightLeg" -> builder.rightLeg;
|
||||||
|
case "leftLeg" -> builder.leftLeg;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable parts selectively based on ownership and keyframe presence.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Owned parts: always enabled (the item controls these bones)</li>
|
||||||
|
* <li>Free parts WITH keyframes: enabled (the GLB has animation data for them)</li>
|
||||||
|
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
|
||||||
|
* <li>Other items' parts: disabled (pass through to their own layer)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param builder the animation builder with keyframes already added
|
||||||
|
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||||
|
* @param enabledParts parts the item may animate (owned + free)
|
||||||
|
* @param partsWithKeyframes parts that received actual animation data from the GLB
|
||||||
|
*/
|
||||||
|
private static void enableSelectiveParts(
|
||||||
|
KeyframeAnimation.AnimationBuilder builder,
|
||||||
|
Set<String> ownedParts,
|
||||||
|
Set<String> enabledParts,
|
||||||
|
Set<String> partsWithKeyframes
|
||||||
|
) {
|
||||||
|
String[] allParts = {
|
||||||
|
"head",
|
||||||
|
"body",
|
||||||
|
"rightArm",
|
||||||
|
"leftArm",
|
||||||
|
"rightLeg",
|
||||||
|
"leftLeg",
|
||||||
|
};
|
||||||
|
for (String partName : allParts) {
|
||||||
|
KeyframeAnimation.StateCollection part = getPartByName(
|
||||||
|
builder,
|
||||||
|
partName
|
||||||
|
);
|
||||||
|
if (part != null) {
|
||||||
|
if (ownedParts.contains(partName)) {
|
||||||
|
// Always enable owned parts — the item controls these bones
|
||||||
|
part.fullyEnablePart(false);
|
||||||
|
} else if (
|
||||||
|
enabledParts.contains(partName) &&
|
||||||
|
partsWithKeyframes.contains(partName)
|
||||||
|
) {
|
||||||
|
// Free part WITH keyframes: enable so the GLB animation drives it
|
||||||
|
part.fullyEnablePart(false);
|
||||||
|
} else {
|
||||||
|
// Other item's part, or free part without keyframes: disable.
|
||||||
|
// Disabled parts pass through to the lower-priority context layer.
|
||||||
|
part.setEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/main/java/com/tiedup/remake/client/gltf/GltfRenderLayer.java
Normal file
106
src/main/java/com/tiedup/remake/client/gltf/GltfRenderLayer.java
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.PlayerModel;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.entity.RenderLayerParent;
|
||||||
|
import net.minecraft.client.renderer.entity.layers.RenderLayer;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RenderLayer that renders the glTF mesh (handcuffs) on the player.
|
||||||
|
* Only active when enabled and only renders on the local player.
|
||||||
|
* <p>
|
||||||
|
* Uses the live skinning path: reads live skeleton from HumanoidModel
|
||||||
|
* via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations.
|
||||||
|
* Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class GltfRenderLayer
|
||||||
|
extends RenderLayer<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>>
|
||||||
|
{
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||||
|
|
||||||
|
private static final ResourceLocation CUFFS_MODEL =
|
||||||
|
ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"tiedup",
|
||||||
|
"models/gltf/v2/handcuffs/cuffs_prototype.glb"
|
||||||
|
);
|
||||||
|
|
||||||
|
public GltfRenderLayer(
|
||||||
|
RenderLayerParent<
|
||||||
|
AbstractClientPlayer,
|
||||||
|
PlayerModel<AbstractClientPlayer>
|
||||||
|
> renderer
|
||||||
|
) {
|
||||||
|
super(renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Y translate offset to place the glTF mesh in the MC PoseStack.
|
||||||
|
* <p>
|
||||||
|
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
|
||||||
|
* the PoseStack origin is at the model top (1.501 blocks above feet), Y-down.
|
||||||
|
* The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5.
|
||||||
|
* Translating by 1.501 maps glTF feet to PoseStack feet and head to top.
|
||||||
|
*/
|
||||||
|
private static final float ALIGNMENT_Y = 1.501f;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(
|
||||||
|
PoseStack poseStack,
|
||||||
|
MultiBufferSource buffer,
|
||||||
|
int packedLight,
|
||||||
|
AbstractClientPlayer entity,
|
||||||
|
float limbSwing,
|
||||||
|
float limbSwingAmount,
|
||||||
|
float partialTick,
|
||||||
|
float ageInTicks,
|
||||||
|
float netHeadYaw,
|
||||||
|
float headPitch
|
||||||
|
) {
|
||||||
|
if (!GltfAnimationApplier.isEnabled()) return;
|
||||||
|
if (entity != Minecraft.getInstance().player) return;
|
||||||
|
|
||||||
|
GltfData data = GltfCache.get(CUFFS_MODEL);
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
// Live path: read skeleton from HumanoidModel (after PlayerAnimator)
|
||||||
|
PlayerModel<AbstractClientPlayer> parentModel = this.getParentModel();
|
||||||
|
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
|
||||||
|
parentModel,
|
||||||
|
data,
|
||||||
|
entity
|
||||||
|
);
|
||||||
|
if (joints == null) {
|
||||||
|
// Fallback to GLB-internal path if live reading fails
|
||||||
|
joints = GltfSkinningEngine.computeJointMatrices(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
poseStack.pushPose();
|
||||||
|
|
||||||
|
// Align glTF mesh with MC model (feet-to-feet alignment)
|
||||||
|
poseStack.translate(0, ALIGNMENT_Y, 0);
|
||||||
|
|
||||||
|
GltfMeshRenderer.renderSkinned(
|
||||||
|
data,
|
||||||
|
joints,
|
||||||
|
poseStack,
|
||||||
|
buffer,
|
||||||
|
packedLight,
|
||||||
|
net.minecraft.client.renderer.entity.LivingEntityRenderer.getOverlayCoords(
|
||||||
|
entity,
|
||||||
|
0.0f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
poseStack.popPose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.tiedup.remake.client.gltf;
|
||||||
|
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
import org.joml.Vector4f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CPU-based Linear Blend Skinning (LBS) engine.
|
||||||
|
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
|
||||||
|
* All data is in MC-converted space (consistent with IBMs and vertex positions).
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public final class GltfSkinningEngine {
|
||||||
|
|
||||||
|
private GltfSkinningEngine() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute joint matrices from glTF animation/rest data (default animation).
|
||||||
|
* Each joint matrix = worldTransform * inverseBindMatrix.
|
||||||
|
* Uses MC-converted glTF data throughout for consistency.
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data (MC-converted)
|
||||||
|
* @return array of joint matrices ready for skinning
|
||||||
|
*/
|
||||||
|
public static Matrix4f[] computeJointMatrices(GltfData data) {
|
||||||
|
return computeJointMatricesFromClip(data, data.animation());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute joint matrices with frame interpolation for animated entities.
|
||||||
|
* Uses SLERP for rotations and LERP for translations between adjacent keyframes.
|
||||||
|
*
|
||||||
|
* <p>The {@code time} parameter is in frame-space: 0.0 corresponds to the first
|
||||||
|
* keyframe and {@code frameCount - 1} to the last. Values between integer frames
|
||||||
|
* are interpolated. Out-of-range values are clamped.</p>
|
||||||
|
*
|
||||||
|
* @param data the parsed glTF data (MC-converted)
|
||||||
|
* @param clip the animation clip to sample (null = rest pose for all joints)
|
||||||
|
* @param time time in frame-space (0.0 = first frame, N-1 = last frame)
|
||||||
|
* @return interpolated joint matrices ready for skinning
|
||||||
|
*/
|
||||||
|
public static Matrix4f[] computeJointMatricesAnimated(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
float time
|
||||||
|
) {
|
||||||
|
int jointCount = data.jointCount();
|
||||||
|
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||||
|
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||||
|
|
||||||
|
int[] parents = data.parentJointIndices();
|
||||||
|
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
// Build local transform: translate(interpT) * rotate(interpQ)
|
||||||
|
Matrix4f local = new Matrix4f();
|
||||||
|
local.translate(getInterpolatedTranslation(data, clip, j, time));
|
||||||
|
local.rotate(getInterpolatedRotation(data, clip, j, time));
|
||||||
|
|
||||||
|
// Compose with parent
|
||||||
|
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||||
|
worldTransforms[j] = new Matrix4f(
|
||||||
|
worldTransforms[parents[j]]
|
||||||
|
).mul(local);
|
||||||
|
} else {
|
||||||
|
worldTransforms[j] = new Matrix4f(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||||
|
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||||
|
data.inverseBindMatrices()[j]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jointMatrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: compute joint matrices from a specific animation clip.
|
||||||
|
*/
|
||||||
|
private static Matrix4f[] computeJointMatricesFromClip(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip
|
||||||
|
) {
|
||||||
|
int jointCount = data.jointCount();
|
||||||
|
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||||
|
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||||
|
|
||||||
|
int[] parents = data.parentJointIndices();
|
||||||
|
|
||||||
|
for (int j = 0; j < jointCount; j++) {
|
||||||
|
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
|
||||||
|
Matrix4f local = new Matrix4f();
|
||||||
|
local.translate(getAnimTranslation(data, clip, j));
|
||||||
|
local.rotate(getAnimRotation(data, clip, j));
|
||||||
|
|
||||||
|
// Compose with parent
|
||||||
|
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||||
|
worldTransforms[j] = new Matrix4f(
|
||||||
|
worldTransforms[parents[j]]
|
||||||
|
).mul(local);
|
||||||
|
} else {
|
||||||
|
worldTransforms[j] = new Matrix4f(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||||
|
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||||
|
data.inverseBindMatrices()[j]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jointMatrices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the animation rotation for a joint (MC-converted).
|
||||||
|
* Falls back to rest rotation if no animation.
|
||||||
|
*/
|
||||||
|
private static Quaternionf getAnimRotation(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
int jointIndex
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
clip != null &&
|
||||||
|
jointIndex < clip.rotations().length &&
|
||||||
|
clip.rotations()[jointIndex] != null
|
||||||
|
) {
|
||||||
|
return clip.rotations()[jointIndex][0]; // first frame
|
||||||
|
}
|
||||||
|
return data.restRotations()[jointIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the animation translation for a joint (MC-converted).
|
||||||
|
* Falls back to rest translation if no animation translation exists.
|
||||||
|
*/
|
||||||
|
private static Vector3f getAnimTranslation(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
int jointIndex
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
clip != null &&
|
||||||
|
clip.translations() != null &&
|
||||||
|
jointIndex < clip.translations().length &&
|
||||||
|
clip.translations()[jointIndex] != null
|
||||||
|
) {
|
||||||
|
return clip.translations()[jointIndex][0]; // first frame
|
||||||
|
}
|
||||||
|
return data.restTranslations()[jointIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Interpolated accessors (for computeJointMatricesAnimated) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an interpolated rotation for a joint at a fractional frame time.
|
||||||
|
* Uses SLERP between the two bounding keyframes.
|
||||||
|
*
|
||||||
|
* <p>Falls back to rest rotation when the clip is null or has no rotation
|
||||||
|
* data for the given joint. A single-frame channel returns that frame directly.</p>
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param clip animation clip (may be null)
|
||||||
|
* @param jointIndex joint to query
|
||||||
|
* @param time frame-space time (clamped internally)
|
||||||
|
* @return new Quaternionf with the interpolated rotation (never mutates source data)
|
||||||
|
*/
|
||||||
|
private static Quaternionf getInterpolatedRotation(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
int jointIndex,
|
||||||
|
float time
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
clip == null ||
|
||||||
|
jointIndex >= clip.rotations().length ||
|
||||||
|
clip.rotations()[jointIndex] == null
|
||||||
|
) {
|
||||||
|
// No animation data for this joint -- use rest pose (copy to avoid mutation)
|
||||||
|
Quaternionf rest = data.restRotations()[jointIndex];
|
||||||
|
return new Quaternionf(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
Quaternionf[] frames = clip.rotations()[jointIndex];
|
||||||
|
if (frames.length == 1) {
|
||||||
|
return new Quaternionf(frames[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp time to valid range [0, frameCount-1]
|
||||||
|
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
|
||||||
|
int f0 = (int) Math.floor(clamped);
|
||||||
|
int f1 = Math.min(f0 + 1, frames.length - 1);
|
||||||
|
float alpha = clamped - f0;
|
||||||
|
|
||||||
|
if (alpha < 1e-6f || f0 == f1) {
|
||||||
|
return new Quaternionf(frames[f0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLERP: create a copy of frame0 and slerp toward frame1
|
||||||
|
return new Quaternionf(frames[f0]).slerp(frames[f1], alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an interpolated translation for a joint at a fractional frame time.
|
||||||
|
* Uses LERP between the two bounding keyframes.
|
||||||
|
*
|
||||||
|
* <p>Falls back to rest translation when the clip is null, the clip has no
|
||||||
|
* translation data at all, or has no translation data for the given joint.
|
||||||
|
* A single-frame channel returns that frame directly.</p>
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param clip animation clip (may be null)
|
||||||
|
* @param jointIndex joint to query
|
||||||
|
* @param time frame-space time (clamped internally)
|
||||||
|
* @return new Vector3f with the interpolated translation (never mutates source data)
|
||||||
|
*/
|
||||||
|
private static Vector3f getInterpolatedTranslation(
|
||||||
|
GltfData data,
|
||||||
|
GltfData.AnimationClip clip,
|
||||||
|
int jointIndex,
|
||||||
|
float time
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
clip == null ||
|
||||||
|
clip.translations() == null ||
|
||||||
|
jointIndex >= clip.translations().length ||
|
||||||
|
clip.translations()[jointIndex] == null
|
||||||
|
) {
|
||||||
|
// No animation data for this joint -- use rest pose (copy to avoid mutation)
|
||||||
|
Vector3f rest = data.restTranslations()[jointIndex];
|
||||||
|
return new Vector3f(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3f[] frames = clip.translations()[jointIndex];
|
||||||
|
if (frames.length == 1) {
|
||||||
|
return new Vector3f(frames[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp time to valid range [0, frameCount-1]
|
||||||
|
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
|
||||||
|
int f0 = (int) Math.floor(clamped);
|
||||||
|
int f1 = Math.min(f0 + 1, frames.length - 1);
|
||||||
|
float alpha = clamped - f0;
|
||||||
|
|
||||||
|
if (alpha < 1e-6f || f0 == f1) {
|
||||||
|
return new Vector3f(frames[f0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LERP: create a copy of frame0 and lerp toward frame1
|
||||||
|
return new Vector3f(frames[f0]).lerp(frames[f1], alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skin a single vertex using Linear Blend Skinning.
|
||||||
|
*
|
||||||
|
* <p>Callers should pre-allocate {@code tmpPos} and {@code tmpNorm} and reuse
|
||||||
|
* them across all vertices in a mesh to avoid per-vertex allocations (12k+
|
||||||
|
* allocations per frame for a typical mesh).</p>
|
||||||
|
*
|
||||||
|
* @param data parsed glTF data
|
||||||
|
* @param vertexIdx index into the vertex arrays
|
||||||
|
* @param jointMatrices joint matrices from computeJointMatrices
|
||||||
|
* @param outPos output skinned position (3 floats)
|
||||||
|
* @param outNormal output skinned normal (3 floats)
|
||||||
|
* @param tmpPos pre-allocated scratch Vector4f for position transforms
|
||||||
|
* @param tmpNorm pre-allocated scratch Vector4f for normal transforms
|
||||||
|
*/
|
||||||
|
public static void skinVertex(
|
||||||
|
GltfData data,
|
||||||
|
int vertexIdx,
|
||||||
|
Matrix4f[] jointMatrices,
|
||||||
|
float[] outPos,
|
||||||
|
float[] outNormal,
|
||||||
|
Vector4f tmpPos,
|
||||||
|
Vector4f tmpNorm
|
||||||
|
) {
|
||||||
|
float[] positions = data.positions();
|
||||||
|
float[] normals = data.normals();
|
||||||
|
int[] joints = data.joints();
|
||||||
|
float[] weights = data.weights();
|
||||||
|
|
||||||
|
// Rest position
|
||||||
|
float vx = positions[vertexIdx * 3];
|
||||||
|
float vy = positions[vertexIdx * 3 + 1];
|
||||||
|
float vz = positions[vertexIdx * 3 + 2];
|
||||||
|
|
||||||
|
// Rest normal
|
||||||
|
float nx = normals[vertexIdx * 3];
|
||||||
|
float ny = normals[vertexIdx * 3 + 1];
|
||||||
|
float nz = normals[vertexIdx * 3 + 2];
|
||||||
|
|
||||||
|
// LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest)
|
||||||
|
float sx = 0,
|
||||||
|
sy = 0,
|
||||||
|
sz = 0;
|
||||||
|
float snx = 0,
|
||||||
|
sny = 0,
|
||||||
|
snz = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int ji = joints[vertexIdx * 4 + i];
|
||||||
|
float w = weights[vertexIdx * 4 + i];
|
||||||
|
if (w <= 0.0f || ji >= jointMatrices.length) continue;
|
||||||
|
|
||||||
|
Matrix4f jm = jointMatrices[ji];
|
||||||
|
|
||||||
|
// Transform position
|
||||||
|
tmpPos.set(vx, vy, vz, 1.0f);
|
||||||
|
jm.transform(tmpPos);
|
||||||
|
sx += w * tmpPos.x;
|
||||||
|
sy += w * tmpPos.y;
|
||||||
|
sz += w * tmpPos.z;
|
||||||
|
|
||||||
|
// Transform normal (ignore translation)
|
||||||
|
tmpNorm.set(nx, ny, nz, 0.0f);
|
||||||
|
jm.transform(tmpNorm);
|
||||||
|
snx += w * tmpNorm.x;
|
||||||
|
sny += w * tmpNorm.y;
|
||||||
|
snz += w * tmpNorm.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
outPos[0] = sx;
|
||||||
|
outPos[1] = sy;
|
||||||
|
outPos[2] = sz;
|
||||||
|
|
||||||
|
// Normalize the normal
|
||||||
|
float len = (float) Math.sqrt(snx * snx + sny * sny + snz * snz);
|
||||||
|
if (len > 0.0001f) {
|
||||||
|
outNormal[0] = snx / len;
|
||||||
|
outNormal[1] = sny / len;
|
||||||
|
outNormal[2] = snz / len;
|
||||||
|
} else {
|
||||||
|
outNormal[0] = 0;
|
||||||
|
outNormal[1] = 1;
|
||||||
|
outNormal[2] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.tiedup.remake.client.gui.overlays;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||||
|
import com.tiedup.remake.client.state.ClientLaborState;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||||
|
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay that shows labor task progress bar.
|
||||||
|
* Displayed in the top-right corner when a labor task is active.
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||||
|
public class LaborProgressOverlay {
|
||||||
|
|
||||||
|
// Bar dimensions
|
||||||
|
private static final int BAR_WIDTH = 150;
|
||||||
|
private static final int BAR_HEIGHT = 10;
|
||||||
|
private static final int PADDING = 6;
|
||||||
|
private static final int MARGIN = 10;
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
private static float smoothProgress = 0.0f;
|
||||||
|
private static final float SMOOTH_SPEED = 0.1f;
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||||
|
// Render after hotbar
|
||||||
|
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an active task
|
||||||
|
if (!ClientLaborState.hasActiveTask()) {
|
||||||
|
// Fade out smoothly
|
||||||
|
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
|
||||||
|
if (smoothProgress <= 0.01f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Smooth interpolation towards target
|
||||||
|
float target = ClientLaborState.getProgressFraction();
|
||||||
|
smoothProgress += (target - smoothProgress) * SMOOTH_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GuiGraphics graphics = event.getGuiGraphics();
|
||||||
|
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||||
|
|
||||||
|
// Position in top-right corner
|
||||||
|
int x = screenWidth - BAR_WIDTH - PADDING * 2 - MARGIN;
|
||||||
|
int y = MARGIN;
|
||||||
|
|
||||||
|
// Calculate panel dimensions
|
||||||
|
int panelWidth = BAR_WIDTH + PADDING * 2;
|
||||||
|
int panelHeight = BAR_HEIGHT + PADDING * 2 + mc.font.lineHeight * 2 + 6;
|
||||||
|
|
||||||
|
// Background panel
|
||||||
|
graphics.fill(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
x + panelWidth,
|
||||||
|
y + panelHeight,
|
||||||
|
GuiColors.withAlpha(GuiColors.BG_DARK, 220)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
GuiRenderUtil.drawBorder(
|
||||||
|
graphics,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
panelWidth,
|
||||||
|
panelHeight,
|
||||||
|
GuiColors.ACCENT_TAN
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task description
|
||||||
|
String description = ClientLaborState.getTaskDescription();
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
description,
|
||||||
|
x + PADDING,
|
||||||
|
y + PADDING,
|
||||||
|
GuiColors.TEXT_WHITE,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress bar position
|
||||||
|
int barX = x + PADDING;
|
||||||
|
int barY = y + PADDING + mc.font.lineHeight + 4;
|
||||||
|
|
||||||
|
// Progress bar background
|
||||||
|
graphics.fill(
|
||||||
|
barX,
|
||||||
|
barY,
|
||||||
|
barX + BAR_WIDTH,
|
||||||
|
barY + BAR_HEIGHT,
|
||||||
|
GuiColors.BG_LIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress bar fill
|
||||||
|
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
|
||||||
|
int fillColor =
|
||||||
|
smoothProgress >= 1.0f ? GuiColors.SUCCESS : GuiColors.ACCENT_TAN;
|
||||||
|
graphics.fill(
|
||||||
|
barX,
|
||||||
|
barY,
|
||||||
|
barX + fillWidth,
|
||||||
|
barY + BAR_HEIGHT,
|
||||||
|
fillColor
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress bar border
|
||||||
|
GuiRenderUtil.drawBorder(
|
||||||
|
graphics,
|
||||||
|
barX,
|
||||||
|
barY,
|
||||||
|
BAR_WIDTH,
|
||||||
|
BAR_HEIGHT,
|
||||||
|
GuiColors.BORDER_LIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress text inside bar
|
||||||
|
String progressStr = ClientLaborState.getProgressString();
|
||||||
|
int textWidth = mc.font.width(progressStr);
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
progressStr,
|
||||||
|
barX + (BAR_WIDTH - textWidth) / 2,
|
||||||
|
barY + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
|
||||||
|
GuiColors.TEXT_WHITE,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Value text below bar
|
||||||
|
String valueStr = ClientLaborState.getValueEmeralds() + " emeralds";
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
valueStr,
|
||||||
|
x + PADDING,
|
||||||
|
barY + BAR_HEIGHT + 2,
|
||||||
|
GuiColors.TEXT_GRAY,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.tiedup.remake.client.gui.overlays;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.tasks.PlayerStateTask;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||||
|
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay that shows a progress bar for tying/untying/struggling actions.
|
||||||
|
* Displayed above the hotbar when an action is in progress.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||||
|
public class ProgressOverlay {
|
||||||
|
|
||||||
|
// Bar dimensions
|
||||||
|
private static final int BAR_WIDTH = 200;
|
||||||
|
private static final int BAR_HEIGHT = 12;
|
||||||
|
private static final int PADDING = 4;
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
private static float smoothProgress = 0.0f;
|
||||||
|
private static final float SMOOTH_SPEED = 0.15f;
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||||
|
// Render after crosshair
|
||||||
|
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
Player player = mc.player;
|
||||||
|
if (player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for active actions
|
||||||
|
ProgressInfo info = getActiveProgress(state);
|
||||||
|
if (info == null) {
|
||||||
|
// Fade out smoothly
|
||||||
|
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
|
||||||
|
if (smoothProgress <= 0.01f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Smooth interpolation
|
||||||
|
smoothProgress += (info.progress - smoothProgress) * SMOOTH_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info == null && smoothProgress <= 0.01f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GuiGraphics graphics = event.getGuiGraphics();
|
||||||
|
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||||
|
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||||
|
|
||||||
|
// Center horizontally, above hotbar
|
||||||
|
int x = (screenWidth - BAR_WIDTH) / 2;
|
||||||
|
int y = screenHeight - 60;
|
||||||
|
|
||||||
|
// Background panel
|
||||||
|
int panelHeight = BAR_HEIGHT + PADDING * 2 + 12; // +12 for text
|
||||||
|
graphics.fill(
|
||||||
|
x - PADDING,
|
||||||
|
y - PADDING,
|
||||||
|
x + BAR_WIDTH + PADDING,
|
||||||
|
y + panelHeight,
|
||||||
|
GuiColors.withAlpha(GuiColors.BG_DARK, 200)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress bar background
|
||||||
|
graphics.fill(x, y, x + BAR_WIDTH, y + BAR_HEIGHT, GuiColors.BG_LIGHT);
|
||||||
|
|
||||||
|
// Progress bar fill
|
||||||
|
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
|
||||||
|
int fillColor = info != null ? info.color : GuiColors.ACCENT_TAN;
|
||||||
|
graphics.fill(x, y, x + fillWidth, y + BAR_HEIGHT, fillColor);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
GuiRenderUtil.drawBorder(
|
||||||
|
graphics,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
BAR_WIDTH,
|
||||||
|
BAR_HEIGHT,
|
||||||
|
GuiColors.BORDER_LIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
// Percentage text
|
||||||
|
String percent = String.format("%.0f%%", smoothProgress * 100);
|
||||||
|
int textWidth = mc.font.width(percent);
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
percent,
|
||||||
|
x + (BAR_WIDTH - textWidth) / 2,
|
||||||
|
y + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
|
||||||
|
GuiColors.TEXT_WHITE,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Description text
|
||||||
|
if (info != null) {
|
||||||
|
graphics.drawCenteredString(
|
||||||
|
mc.font,
|
||||||
|
info.description,
|
||||||
|
x + BAR_WIDTH / 2,
|
||||||
|
y + BAR_HEIGHT + PADDING,
|
||||||
|
GuiColors.TEXT_GRAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active progress information.
|
||||||
|
*/
|
||||||
|
private static ProgressInfo getActiveProgress(PlayerBindState state) {
|
||||||
|
// Check client tying task
|
||||||
|
PlayerStateTask tyingTask = state.getClientTyingTask();
|
||||||
|
if (tyingTask != null && !tyingTask.isOutdated()) {
|
||||||
|
float progress = tyingTask.getProgress();
|
||||||
|
Component text = getTyingText(tyingTask);
|
||||||
|
return new ProgressInfo(progress, GuiColors.WARNING, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client untying task
|
||||||
|
PlayerStateTask untyingTask = state.getClientUntyingTask();
|
||||||
|
if (untyingTask != null && !untyingTask.isOutdated()) {
|
||||||
|
float progress = untyingTask.getProgress();
|
||||||
|
Component text = getUntyingText(untyingTask);
|
||||||
|
return new ProgressInfo(progress, GuiColors.SUCCESS, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client feeding task
|
||||||
|
PlayerStateTask feedingTask = state.getClientFeedingTask();
|
||||||
|
if (feedingTask != null && !feedingTask.isOutdated()) {
|
||||||
|
float progress = feedingTask.getProgress();
|
||||||
|
Component text = getFeedingText(feedingTask);
|
||||||
|
return new ProgressInfo(progress, GuiColors.ACCENT_TAN, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate text for tying progress based on role.
|
||||||
|
*/
|
||||||
|
private static Component getTyingText(PlayerStateTask task) {
|
||||||
|
String otherName = task.getOtherEntityName();
|
||||||
|
if (otherName == null || otherName.isEmpty()) {
|
||||||
|
otherName = "???";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.isKidnapper()) {
|
||||||
|
// Kidnapper sees: "Tying [target]..."
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.tying_target",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Victim sees: "[kidnapper] is tying you up!"
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.being_tied_by",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate text for untying progress based on role.
|
||||||
|
*/
|
||||||
|
private static Component getUntyingText(PlayerStateTask task) {
|
||||||
|
String otherName = task.getOtherEntityName();
|
||||||
|
if (otherName == null || otherName.isEmpty()) {
|
||||||
|
otherName = "???";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.isKidnapper()) {
|
||||||
|
// Helper sees: "Untying [target]..."
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.untying_target",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Victim sees: "[helper] is untying you!"
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.being_untied_by",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate text for feeding progress based on role.
|
||||||
|
*/
|
||||||
|
private static Component getFeedingText(PlayerStateTask task) {
|
||||||
|
String otherName = task.getOtherEntityName();
|
||||||
|
if (otherName == null || otherName.isEmpty()) {
|
||||||
|
otherName = "???";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.isKidnapper()) {
|
||||||
|
// Feeder sees: "Feeding [target]..."
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.feeding_target",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Target sees: "[feeder] is feeding you!"
|
||||||
|
return Component.translatable(
|
||||||
|
"gui.tiedup.action.being_fed_by",
|
||||||
|
otherName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress information container.
|
||||||
|
*/
|
||||||
|
private static class ProgressInfo {
|
||||||
|
|
||||||
|
final float progress;
|
||||||
|
final int color;
|
||||||
|
final Component description;
|
||||||
|
|
||||||
|
ProgressInfo(float progress, int color, Component description) {
|
||||||
|
this.progress = progress;
|
||||||
|
this.color = color;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.tiedup.remake.client.gui.overlays;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||||
|
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||||
|
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay that shows status icons when player is restrained.
|
||||||
|
* Icons appear in top-left corner showing current bondage state.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||||
|
public class StatusOverlay {
|
||||||
|
|
||||||
|
// Icon size and spacing
|
||||||
|
private static final int ICON_SIZE = 16;
|
||||||
|
private static final int PADDING = 2;
|
||||||
|
private static final int MARGIN = 5;
|
||||||
|
|
||||||
|
// Visibility toggle
|
||||||
|
private static boolean visible = true;
|
||||||
|
|
||||||
|
// Icon colors (use centralized GuiColors)
|
||||||
|
private static final int COLOR_BOUND = GuiColors.TYPE_BIND;
|
||||||
|
private static final int COLOR_GAGGED = GuiColors.TYPE_GAG;
|
||||||
|
private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD;
|
||||||
|
private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS;
|
||||||
|
private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR;
|
||||||
|
private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle overlay visibility.
|
||||||
|
*/
|
||||||
|
public static void toggleVisibility() {
|
||||||
|
visible = !visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set overlay visibility.
|
||||||
|
*/
|
||||||
|
public static void setVisible(boolean vis) {
|
||||||
|
visible = vis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if overlay is visible.
|
||||||
|
*/
|
||||||
|
public static boolean isVisible() {
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||||
|
// Only render after hotbar
|
||||||
|
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
Player player = mc.player;
|
||||||
|
if (player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show if not restrained at all
|
||||||
|
if (
|
||||||
|
!state.isTiedUp() &&
|
||||||
|
!state.isGagged() &&
|
||||||
|
!state.isBlindfolded() &&
|
||||||
|
!state.hasEarplugs() &&
|
||||||
|
!state.hasCollar() &&
|
||||||
|
!state.hasMittens()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GuiGraphics graphics = event.getGuiGraphics();
|
||||||
|
|
||||||
|
// Position: top-left corner
|
||||||
|
int x = MARGIN;
|
||||||
|
int y = MARGIN;
|
||||||
|
|
||||||
|
// Background panel
|
||||||
|
int iconCount = countActiveIcons(state);
|
||||||
|
if (iconCount > 0) {
|
||||||
|
int cols = Math.min(iconCount, 3);
|
||||||
|
int rows = (iconCount + 2) / 3;
|
||||||
|
int panelWidth = cols * (ICON_SIZE + PADDING) + PADDING;
|
||||||
|
int panelHeight = rows * (ICON_SIZE + PADDING) + PADDING;
|
||||||
|
|
||||||
|
// Semi-transparent background
|
||||||
|
graphics.fill(
|
||||||
|
x - PADDING,
|
||||||
|
y - PADDING,
|
||||||
|
x + panelWidth,
|
||||||
|
y + panelHeight,
|
||||||
|
GuiColors.withAlpha(GuiColors.BG_DARK, 180)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render icons in grid (max 3 per row)
|
||||||
|
int iconIndex = 0;
|
||||||
|
|
||||||
|
if (state.isTiedUp()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_BOUND, "B");
|
||||||
|
}
|
||||||
|
if (state.isGagged()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_GAGGED, "G");
|
||||||
|
}
|
||||||
|
if (state.isBlindfolded()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_BLIND, "X");
|
||||||
|
}
|
||||||
|
if (state.hasEarplugs()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_DEAF, "D");
|
||||||
|
}
|
||||||
|
if (state.hasCollar()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_COLLAR, "C");
|
||||||
|
}
|
||||||
|
if (state.hasMittens()) {
|
||||||
|
renderIcon(graphics, x, y, iconIndex++, COLOR_MITTENS, "M");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count active status icons.
|
||||||
|
*/
|
||||||
|
private static int countActiveIcons(PlayerBindState state) {
|
||||||
|
int count = 0;
|
||||||
|
if (state.isTiedUp()) count++;
|
||||||
|
if (state.isGagged()) count++;
|
||||||
|
if (state.isBlindfolded()) count++;
|
||||||
|
if (state.hasEarplugs()) count++;
|
||||||
|
if (state.hasCollar()) count++;
|
||||||
|
if (state.hasMittens()) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single status icon.
|
||||||
|
* Uses colored squares with letters as placeholders until proper textures are made.
|
||||||
|
*/
|
||||||
|
private static void renderIcon(
|
||||||
|
GuiGraphics graphics,
|
||||||
|
int baseX,
|
||||||
|
int baseY,
|
||||||
|
int index,
|
||||||
|
int color,
|
||||||
|
String letter
|
||||||
|
) {
|
||||||
|
int col = index % 3;
|
||||||
|
int row = index / 3;
|
||||||
|
|
||||||
|
int x = baseX + col * (ICON_SIZE + PADDING);
|
||||||
|
int y = baseY + row * (ICON_SIZE + PADDING);
|
||||||
|
|
||||||
|
// Icon background
|
||||||
|
graphics.fill(x, y, x + ICON_SIZE, y + ICON_SIZE, color);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
GuiRenderUtil.drawBorder(
|
||||||
|
graphics,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
ICON_SIZE,
|
||||||
|
ICON_SIZE,
|
||||||
|
GuiColors.darken(color, 0.3f)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Letter (centered)
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
int textWidth = mc.font.width(letter);
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
letter,
|
||||||
|
x + (ICON_SIZE - textWidth) / 2,
|
||||||
|
y + (ICON_SIZE - mc.font.lineHeight) / 2 + 1,
|
||||||
|
GuiColors.TEXT_WHITE,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.tiedup.remake.client.gui.overlays;
|
||||||
|
|
||||||
|
import com.tiedup.remake.client.ModKeybindings;
|
||||||
|
import com.tiedup.remake.core.TiedUpMod;
|
||||||
|
import com.tiedup.remake.state.IBondageState;
|
||||||
|
import com.tiedup.remake.util.KidnappedHelper;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.phys.EntityHitResult;
|
||||||
|
import net.minecraft.world.phys.HitResult;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||||
|
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||||
|
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||||
|
import net.minecraftforge.fml.common.Mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay that shows a tooltip when looking at a tied player/NPC.
|
||||||
|
* Displayed below the crosshair when aiming at a restrainable entity.
|
||||||
|
*
|
||||||
|
* Shows contextual hints like:
|
||||||
|
* - "[Right-click] Untie" when looking at tied entity
|
||||||
|
* - "[Right-click] Remove gag" when looking at gagged entity
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||||
|
public class UntieTooltipOverlay {
|
||||||
|
|
||||||
|
/** Offset below the crosshair */
|
||||||
|
private static final int Y_OFFSET = 12;
|
||||||
|
|
||||||
|
/** Text color (white with slight transparency) */
|
||||||
|
private static final int TEXT_COLOR = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
/** Shadow color */
|
||||||
|
private static final int SHADOW_COLOR = 0x80000000;
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||||
|
// Render after crosshair
|
||||||
|
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what we're looking at
|
||||||
|
HitResult hitResult = mc.hitResult;
|
||||||
|
if (!(hitResult instanceof EntityHitResult entityHit)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Entity target = entityHit.getEntity();
|
||||||
|
if (!(target instanceof LivingEntity livingTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the target's kidnapped state
|
||||||
|
IBondageState state = KidnappedHelper.getKidnappedState(livingTarget);
|
||||||
|
if (state == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine what action is available
|
||||||
|
String tooltip = getTooltipText(state);
|
||||||
|
if (tooltip == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the tooltip below the crosshair
|
||||||
|
GuiGraphics graphics = event.getGuiGraphics();
|
||||||
|
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||||
|
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||||
|
|
||||||
|
int centerX = screenWidth / 2;
|
||||||
|
int centerY = screenHeight / 2;
|
||||||
|
|
||||||
|
// Draw text centered below crosshair
|
||||||
|
int textWidth = mc.font.width(tooltip);
|
||||||
|
int textX = centerX - textWidth / 2;
|
||||||
|
int textY = centerY + Y_OFFSET;
|
||||||
|
|
||||||
|
// Draw shadow first for better readability
|
||||||
|
graphics.drawString(
|
||||||
|
mc.font,
|
||||||
|
tooltip,
|
||||||
|
textX + 1,
|
||||||
|
textY + 1,
|
||||||
|
SHADOW_COLOR,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
graphics.drawString(mc.font, tooltip, textX, textY, TEXT_COLOR, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate tooltip text based on the target's state.
|
||||||
|
* Respects lock status - locked items show "Help struggle" instead.
|
||||||
|
*
|
||||||
|
* @param state The target's kidnapped state
|
||||||
|
* @return The tooltip text, or null if no action is available
|
||||||
|
*/
|
||||||
|
private static String getTooltipText(IBondageState state) {
|
||||||
|
// Priority order: untie > ungag > unblindfold > uncollar
|
||||||
|
// If item is locked, can only help struggle (not remove directly)
|
||||||
|
String struggleKey =
|
||||||
|
"[" +
|
||||||
|
ModKeybindings.STRUGGLE_KEY.getTranslatedKeyMessage().getString() +
|
||||||
|
"]";
|
||||||
|
|
||||||
|
if (state.isTiedUp()) {
|
||||||
|
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||||
|
if (state.isLocked(bind, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Untie";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isGagged()) {
|
||||||
|
// Check if player is holding food → force feeding prompt
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (
|
||||||
|
mc.player != null &&
|
||||||
|
mc.player.getMainHandItem().getItem().isEdible()
|
||||||
|
) {
|
||||||
|
return "[Right-click] Feed";
|
||||||
|
}
|
||||||
|
ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH);
|
||||||
|
if (state.isLocked(gag, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Remove gag";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isBlindfolded()) {
|
||||||
|
ItemStack blindfold = state.getEquipment(BodyRegionV2.EYES);
|
||||||
|
if (state.isLocked(blindfold, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Remove blindfold";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hasCollar()) {
|
||||||
|
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
|
||||||
|
if (state.isLocked(collar, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Remove collar";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hasEarplugs()) {
|
||||||
|
ItemStack earplugs = state.getEquipment(BodyRegionV2.EARS);
|
||||||
|
if (state.isLocked(earplugs, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Remove earplugs";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hasMittens()) {
|
||||||
|
ItemStack mittens = state.getEquipment(BodyRegionV2.HANDS);
|
||||||
|
if (state.isLocked(mittens, false)) {
|
||||||
|
return struggleKey + " Help struggle";
|
||||||
|
}
|
||||||
|
return "[Right-click] Remove mittens";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.tiedup.remake.client.gui.screens;
|
||||||
|
|
||||||
|
import com.tiedup.remake.network.ModNetwork;
|
||||||
|
import com.tiedup.remake.network.item.PacketAdjustItem;
|
||||||
|
import com.tiedup.remake.state.PlayerBindState;
|
||||||
|
import com.tiedup.remake.v2.BodyRegionV2;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraftforge.api.distmarker.Dist;
|
||||||
|
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen for adjusting Y position of the player's own gags and blindfolds.
|
||||||
|
* Shows 3D preview of player with real-time adjustment.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@OnlyIn(Dist.CLIENT)
|
||||||
|
public class AdjustmentScreen extends BaseAdjustmentScreen {
|
||||||
|
|
||||||
|
public AdjustmentScreen() {
|
||||||
|
super(Component.translatable("gui.tiedup.adjust_position"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ABSTRACT IMPLEMENTATIONS ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected LivingEntity getTargetEntity() {
|
||||||
|
return this.minecraft.player;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemStack getGag() {
|
||||||
|
Player player = this.minecraft.player;
|
||||||
|
if (player == null) return ItemStack.EMPTY;
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) return ItemStack.EMPTY;
|
||||||
|
|
||||||
|
return state.getEquipment(BodyRegionV2.MOUTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemStack getBlindfold() {
|
||||||
|
Player player = this.minecraft.player;
|
||||||
|
if (player == null) return ItemStack.EMPTY;
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||||
|
if (state == null) return ItemStack.EMPTY;
|
||||||
|
|
||||||
|
return state.getEquipment(BodyRegionV2.EYES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void sendAdjustment(Mode mode, float value, float scale) {
|
||||||
|
BodyRegionV2 region = switch (mode) {
|
||||||
|
case GAG -> BodyRegionV2.MOUTH;
|
||||||
|
case BLINDFOLD -> BodyRegionV2.EYES;
|
||||||
|
case BOTH -> null; // Handled separately in applyAdjustment
|
||||||
|
};
|
||||||
|
|
||||||
|
if (region != null) {
|
||||||
|
ModNetwork.sendToServer(new PacketAdjustItem(region, value, scale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STATIC HELPERS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this screen should be openable (player has adjustable items).
|
||||||
|
*/
|
||||||
|
public static boolean canOpen() {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (mc.player == null) return false;
|
||||||
|
|
||||||
|
PlayerBindState state = PlayerBindState.getInstance(mc.player);
|
||||||
|
if (state == null) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!state.getEquipment(BodyRegionV2.MOUTH).isEmpty() ||
|
||||||
|
!state.getEquipment(BodyRegionV2.EYES).isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user