3 Commits

Author SHA1 Message Date
73d70e212d Merge pull request 'init : add base code to develop' (#1) from init/updatecode into develop
Reviewed-on: #1
2026-04-11 23:05:17 +00:00
NotEvil
bb731bd488 Add artist guide for 3D item creation
Comprehensive documentation for creating custom items and furniture
using Blender and GLB files. All JSON examples include the required
animation_bones field with correct bone mappings.
2026-04-12 01:02:26 +02:00
NotEvil
f6466360b6 Clean repo for open source release
Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
2026-04-12 00:51:22 +02:00
1948 changed files with 239527 additions and 1 deletions

5
.gitattributes vendored Normal file
View 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
View 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
View 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
View 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

171
Makefile Normal file
View File

@@ -0,0 +1,171 @@
# 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
##@ 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

122
README.md
View File

@@ -1 +1,121 @@
# 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.
## 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
View 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
View 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
}

1502
docs/ARTIST_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

62
gradle.properties Normal file
View 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

Binary file not shown.

View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

13
settings.gradle Normal file
View 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'
}

View 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);
}
}

View File

@@ -0,0 +1,34 @@
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.
*
* Phase 16: Blocks
*
* 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
);
}
}

View 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;
}
}

View File

@@ -0,0 +1,305 @@
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.
*
* Phase 16: Blocks
*
* 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)
);
}
}
}

View 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);
}
}
}
}

View File

@@ -0,0 +1,395 @@
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.
*
* Phase 16: Blocks
*
* 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)
);
}
}
}

View File

@@ -0,0 +1,254 @@
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.
*
* Phase 16: Blocks
*
* 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)
);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.tiedup.remake.blocks;
/**
* Marker interface for blocks that can have bondage items loaded into them.
*
* Phase 16: Blocks
*
* 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
}

View File

@@ -0,0 +1,225 @@
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
*
* Phase 16: Blocks
*
* 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;
}
}

View File

@@ -0,0 +1,342 @@
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.
*
* Phase 16: Blocks
*
* 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);
}
}
}

View File

@@ -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
);
}
}
}

View File

@@ -0,0 +1,91 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
/**
* Interface for BlockEntities that store bondage items.
*
* Phase 16: Blocks
*
* 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();
}

View File

@@ -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
);
}
}
}

View File

@@ -0,0 +1,32 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for kidnap bomb blocks.
*
* Phase 16: 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
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
*
* Phase 16: Blocks
*
* 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)
);
}

View File

@@ -0,0 +1,28 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for rope trap blocks.
*
* Phase 16: 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);
}
}

View File

@@ -0,0 +1,230 @@
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.
*
* Phase 16: 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);
}
}

View File

@@ -0,0 +1,210 @@
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.
*
* Phase 17: Bounty System
*
* 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")
);
}
}

View File

@@ -0,0 +1,407 @@
package com.tiedup.remake.bounty;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.core.SettingsAccessor;
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.
*
* Phase 17: Bounty System
*
* 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;
}
}

View 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()
);
}
}
}

View File

@@ -0,0 +1,158 @@
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();
}
}

View File

@@ -0,0 +1,576 @@
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;
}
}

View 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() +
"}"
);
}
}

View 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
}
}

View 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();
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,641 @@
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;
/**
* Phase 2: SavedData registry for confiscated player inventories.
*
* 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;
}
}

View 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();
}
}

View 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;
}
}

View 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,
};
}
}

View 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,
}

View File

@@ -0,0 +1,155 @@
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;
}
}

View File

@@ -0,0 +1,446 @@
package com.tiedup.remake.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
import com.tiedup.remake.items.base.ItemCollar;
import org.jetbrains.annotations.Nullable;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ILockable;
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.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
import com.tiedup.remake.state.PlayerBindState;
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;
/**
* Phase 7: Client-side keybindings for TiedUp mod.
*
* 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 - Phase 21: 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"
);
}
}
}
/**
* Phase 21: Handle struggle key press with new flow.
*
* 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
// Phase 2.5: Check if mini-game is enabled
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;
}
}

View 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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,737 @@
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");
}
}

View File

@@ -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) {}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,93 @@
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";
};
}
}

View File

@@ -0,0 +1,118 @@
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;
}
}

View File

@@ -0,0 +1,161 @@
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 org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
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();
}
}

View File

@@ -0,0 +1,121 @@
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 org.jetbrains.annotations.Nullable;
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;
/**
* 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();
}
}

View File

@@ -0,0 +1,151 @@
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 org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* 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()));
}
}

View File

@@ -0,0 +1,344 @@
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 org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* 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);
}
}

View File

@@ -0,0 +1,198 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
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 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]));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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]);
}
}
}

View File

@@ -0,0 +1,111 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.v2.BodyRegionV2;
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 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);
}
}
}

View File

@@ -0,0 +1,135 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
import com.tiedup.remake.v2.BodyRegionV2;
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 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 (Phase 19)
*/
@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) - Phase 19 ===
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 - Phase 19 ===
boolean[] savedLayers = storedLayers.remove(player.getId());
if (savedLayers != null) {
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,318 @@
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.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.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 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.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
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"
);
}
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,198 @@
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.concurrent.ConcurrentHashMap;
import java.util.UUID;
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();
}
}

View File

@@ -0,0 +1,273 @@
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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,234 @@
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;
/**
* Phase 5: Blindfold Rendering
*
* 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -0,0 +1,156 @@
package com.tiedup.remake.client.events;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
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 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();
}
}

View File

@@ -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.v2.bondage.IV2BondageItem;
import com.tiedup.remake.network.selfbondage.PacketSelfBondage;
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
);
}
}

View File

@@ -0,0 +1,628 @@
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;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
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;
}
}
}
}
}
}

View File

@@ -0,0 +1,450 @@
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 org.jetbrains.annotations.Nullable;
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;
/**
* 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;
};
}
}

View File

@@ -0,0 +1,104 @@
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);
}
}

View File

@@ -0,0 +1,67 @@
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");
}
}

View File

@@ -0,0 +1,140 @@
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.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 net.minecraft.server.packs.resources.SimplePreparableReloadListener;
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();
}
}
}
}

View File

@@ -0,0 +1,194 @@
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
) {}
}

View File

@@ -0,0 +1,245 @@
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;
}
}

View File

@@ -0,0 +1,255 @@
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();
}
}
}
}

View File

@@ -0,0 +1,485 @@
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 org.jetbrains.annotations.Nullable;
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.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);
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
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();
}
}

View File

@@ -0,0 +1,296 @@
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;
}
}
}

View File

@@ -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
);
}
}

View File

@@ -0,0 +1,251 @@
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.
*
* Phase 16: GUI Revamp - Progress bar overlay
*/
@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;
}
}
}

View File

@@ -0,0 +1,202 @@
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.
*
* Phase 16: GUI Revamp - Status indicator overlay
*/
@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
);
}
}

View File

@@ -0,0 +1,176 @@
package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.ModKeybindings;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
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;
}
}

View File

@@ -0,0 +1,87 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.item.PacketAdjustItem;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.PlayerBindState;
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.
*
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
*/
@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