mods that hopefully work for a TechAge-based pack

This commit is contained in:
2026-09-09 06:27:51 -06:00
parent 4e6b08d194
commit c27db2a537
6234 changed files with 375578 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+2
View File
@@ -0,0 +1,2 @@
eye_spy.code-workspace
thread from zed.md
+504
View File
@@ -0,0 +1,504 @@
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
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library 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
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random
Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
+23
View File
@@ -0,0 +1,23 @@
License for Code
----------------
Copyright (C) 2026- cjgray24
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
License for Textures, Models and Sounds
---------------------------------------
CC-BY-SA 4.0 UNPORTED. Created by cjgray24
+235
View File
@@ -0,0 +1,235 @@
<div align="center">
<img src="screenshot.png" alt="Eye Spy HUD" width="640">
# Eye Spy
**Real-time node, entity, and item inspection — right on your HUD.**
[![ContentDB](https://img.shields.io/badge/ContentDB-eye__spy-blue)](https://content.luanti.org/packages/cjgray24/eye_spy/)
[![License: LGPL v2.1](https://img.shields.io/badge/license-LGPL%20v2.1-brightgreen)](LICENSE.txt)
[![Luanti 5.8+](https://img.shields.io/badge/Luanti-5.8%2B-orange)](https://www.luanti.org/)
[![Translations](https://img.shields.io/badge/translations-8%20languages-lightgrey)](locale/)
</div>
---
Eye Spy adds a live HUD that shows you everything about whatever you are looking at — the node name, which tool breaks it fastest, how long digging takes, the current light level, crop growth and soil status, entity health, and more. Every detail is configurable through an in-game editor with live preview, and the HUD updates continuously as you look around.
## Contents
- [Screenshots](#screenshots)
- [Features](#features)
- [Getting Started](#getting-started)
- [Commands](#commands)
- [Configuration](#configuration)
- [Advanced Layout Editor](#advanced-layout-editor)
- [Localization](#localization)
- [Compatibility](#compatibility)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
- [License](#license)
## Screenshots
<table>
<tr>
<td><img src="screenshots/hud_growth.png" alt="HUD showing crop growth and soil status" width="400"></td>
<td><img src="screenshots/settings_ui.png" alt="Settings editor with live HUD preview" width="400"></td>
</tr>
<tr>
<td align="center">Crop growth and soil info</td>
<td align="center">In-game settings editor with live preview</td>
</tr>
<tr>
<td><img src="screenshots/hud_entity.png" alt="HUD showing entity health in hearts" width="400"></td>
<td><img src="screenshots/layout_editor.png" alt="Advanced Layout Editor with position and panel sliders" width="400"></td>
</tr>
<tr>
<td align="center">Entity health display</td>
<td align="center">Advanced Layout Editor</td>
</tr>
</table>
## Features
### HUD Information
- **Node name and icon** — description and texture of the node you are pointing at
- **Tool requirement** — which tool type breaks the node (Pickaxe, Shovel, Axe, Sword, Hand, Shears), color-coded: green for indicating harvestability with your current tool, orange for correct tool but unharvestable, red for wrong tool and unharvestable.
- **Dig time** — how many seconds it takes to break the node with your current tool; ∞ for unbreakable blocks
- **Light level** — current ambient light at the target position (0–14)
- **Spawn hint** — estimates whether the spot is dark enough for hostile mob spawning, based on a configurable light threshold (off by default)
- **Crop growth** — visual progress bar and percentage for farming crops, with stall detection when soil or light conditions are wrong
- **Soil status** — wet/dry state and fertility suitability for crop soil; also shows directly when you look at a soil block (part of the growth toggle)
- **Liquid info** — liquid type (Water/Lava, etc.), whether it is a source or flowing, and the level value; when enabled, Eye Spy can scan through liquid and still show the target behind it
- **Entity health** — current and maximum HP as numeric points or ❤ hearts
- **Dropped items** — resolves item entities to their actual name, icon, stack count, durability, and metadata
- **Mod origin** — footer shows which mod provides the node or entity. with optional advanced mode which gives it as "modname:technicalname" instead.
- **Coordinates** — optional footer display of the target's world position
### Customization
- **HUD background color** — full RGB slider control with live preview
- **Auto-contrast text** — title text automatically switches between light and dark based on your chosen background color; semantic colors for info, footer, and soil lines are contrast-adjusted automatically
- **Manual color spectrum** — 6 independent hue sliders for Title, Info, Footer, Line, Growth, and Soil colors when auto-contrast is off
- **7 screen positions** — Top-Middle (default), Top-Left, Top-Right, Middle-Left, Middle-Right, Bottom-Left, Bottom-Right
- **Health display format** — numeric Points or ❤ Hearts
- **Per-feature toggles** — enable or disable Light Level, Spawn Hint, Liquid Info, Dig Time, Growth, Icons, and Coordinates independently; Liquid Info is a single toggle that controls both liquid line visibility and scan-through targeting
- **Footer modes** — Compact (mod name only) or Advanced (mod name + full item identifier)
- **Advanced Layout Editor** — 35+ fine-grained sliders for pixel-level positioning of every HUD element
### Performance
- **Adjustable refresh rate** — 100 ms default in singleplayer, 150 ms in multiplayer, configurable from 50–5000 ms
- **Per-player rate overrides** — server admins can set a different refresh interval for specific players without affecting others
- **Multi-layer caching** — target position, render signature, layout geometry, light samples, tool type results, and line colors are all cached to minimize unnecessary work each tick
- **HUD auto-recovery** — if HUD elements go blank after a long session, Eye Spy detects and restores them automatically
- **Optional performance metrics** — per-stage microsecond timing with avg/p95/max reporting via `/eye_spy_perf`
## Getting Started
### Installation
1. Place the `eye_spy` folder inside your world's `mods/` directory.
2. Enable the mod in the mod selection screen before creating or loading a world, **or** add `load_mod_eye_spy = true` to your `world.mt` file.
3. Launch the game — the HUD appears automatically when you join.
### Quick Start
1. Look at any node, entity, or dropped item to see the HUD.
2. Run `/eye_spy_settings` to open the in-game editor.
3. Use the **RGB sliders** to set your preferred background color.
4. Use the **HUD Alignment** dropdown to move the HUD to a comfortable position.
5. Toggle individual info lines under **Info Lines** to show only what you need.
6. Click **Apply Changes** to save.
## Commands
| Command | Description | Privilege |
|---|---|---|
| `/eye_spy_settings` | Opens the settings and live-preview editor | — |
| `/eye_spy_rate show` | Shows your effective refresh rate and the server default | — |
| `/eye_spy_rate default <ms>` | Sets the server-wide HUD refresh rate (50–5000 ms) | `eye_spy_rate` |
| `/eye_spy_rate player <name> <ms\|default>` | Sets or clears a per-player refresh rate override | `eye_spy_rate` |
| `/eye_spy_perf show` | Prints timing stats (avg / p95 / max) for all internal stages | `eye_spy_rate` |
| `/eye_spy_perf reset` | Clears all collected performance samples | `eye_spy_rate` |
| `/eye_spy_perf on` | Enables runtime performance metrics collection | `eye_spy_rate` |
| `/eye_spy_perf off` | Disables runtime performance metrics collection | `eye_spy_rate` |
| `/eye_spy_toggle` | Toggles your Eye Spy HUD on or off | — |
| `/eye_spy_status` | Shows your current HUD status and active refresh rate | — |
| `/eye_spy_admin disable <name>` | Suppresses the HUD for a player server-side | `eye_spy_admin` |
| `/eye_spy_admin enable <name>` | Lifts a server-side HUD suppression for a player | `eye_spy_admin` |
| `/eye_spy_admin show [name]` | Shows HUD status for one or all online players | `eye_spy_admin` |
> The `eye_spy_rate` and `eye_spy_admin` privileges are granted automatically in singleplayer.
## Configuration
These settings can be placed in `minetest.conf` or configured in the server's settings UI. They apply server-wide and take effect on the next server start.
| Setting | Default | Description |
|---|---|---|
| `eye_spy.show_entities` | `true` | Show entity information in the HUD |
| `eye_spy.show_liquids` | `false` | Enable liquid-aware targeting. When Liquid Info is on, Eye Spy scans through liquid, shows a liquid line, and can still display the target behind it |
| `eye_spy.always_show_hand_in_creative` | `true` | Always display "Hand" as the tool type in creative mode |
| `eye_spy.texture_pack_size` | `None` | Force all HUD icons to a fixed resolution. Use this when textures appear at the wrong size with a custom texture pack. Options: `None`, `16x16px`, `32x32px`, `64x64px`, `128x128px`, `256x256px`, and higher |
| `eye_spy.update_interval` | `0.25` | Base update interval in seconds (used as a fallback; the rate system normally controls timing) |
| `eye_spy.enable_perf_metrics` | `false` | Enable performance metrics collection at startup |
| `eye_spy.show_growth` | `true` | Show crop growth and soil status lines |
| `eye_spy.show_icons` | `true` | Show the target icon in the HUD |
| `eye_spy.default_show_coords` | `false` | Default coordinates visibility for new players |
| `eye_spy.default_show_light_level` | `true` | Default light level visibility for new players |
| `eye_spy.default_show_spawn_hint` | `false` | Default spawn hint visibility for new players |
| `eye_spy.default_show_liquid_info` | `true` | Default liquid info visibility for new players |
| `eye_spy.default_show_dig_time` | `true` | Default dig time visibility for new players |
Per-player settings (colors, position, toggles, layout) are stored in player metadata and are managed entirely through the in-game `/eye_spy_settings` editor.
## Advanced Layout Editor
The Advanced Layout Editor gives you fine-grained control over the exact position and scale of every HUD element. Open it from `/eye_spy_settings` → **Advanced Layout**.
<img src="screenshots/layout_editor.png" alt="Advanced Layout Editor" width="640">
The editor is split into three pages:
| Page | What you can adjust |
|---|---|
| **Position** | Global X/Y offset, per-element X/Y offsets, margins, icon and text base positions |
| **Panel** | Background padding (left/right/top/bottom), extra width/height, background scale % |
| **Text** | Scale percentage for Title, Info, Lines, and Footer text independently |
Use the **Target** dropdown to select which element to adjust (Global, BG, Icon, Title, Subtitle, Lines, or Footer). The **Step** control sets the slider increment — use 1 for precise tweaking and 8 for quick coarse movement. All changes preview live on your HUD as you drag. Use **Reset Target** to restore one element or **Reset All** to return everything to defaults.
## Localization
Eye Spy is translated into 8 languages:
| Language | File |
|---|---|
| Czech | `locale/eye_spy.cs.tr` |
| German | `locale/eye_spy.de.tr` |
| Spanish | `locale/eye_spy.es.tr` |
| French | `locale/eye_spy.fr.tr` |
| Italian | `locale/eye_spy.it.tr` |
| Dutch | `locale/eye_spy.nl.tr` |
| Polish | `locale/eye_spy.pl.tr` |
| Russian | `locale/eye_spy.ru.tr` |
To contribute a new translation or improve an existing one, copy `locale/eye_spy.template.tr`, rename it to `eye_spy.<lang_code>.tr`, and fill in the translations on the right side of each `=` line.
## Compatibility
| Game | Status | Notes |
|---|---|---|
| Minetest Game | ✅ Full | Pickaxe, Shovel, Axe, Sword, Hand tool detection |
| MineClone2 | ✅ Full | Pickaxey, Shovely, Axey, Swordy, Shearsy, Handy detection |
| Mineclonia | ✅ Full | Same as MineClone2 |
| VoxeLibre | ✅ Full | Same as MineClone2 |
### Optional dependencies
| Mod | Purpose |
|---|---|
| `default` | Standard node and tool definitions (Minetest Game) |
| `mcl_core`, `mcl_tools` | Node and tool definitions for MineClone-family games |
| `mcl_doc` | Additional MineClone documentation integration |
Eye Spy has **no required dependencies** and works in any game that follows standard Luanti node conventions.
## Troubleshooting
**HUD not appearing**
Run `/eye_spy_settings` and click Apply Changes to force the HUD to reinitialize. Confirm the mod is listed as enabled for your world in `world.mt`.
**Soil shows as Dry even though water is present**
Eye Spy scans for water within 3 blocks of the soil position. If the water source is further away, the farming mod's hydration ABM may not have updated the soil block yet — the HUD will reflect the correct state once it does. Moving the water to be directly adjacent resolves this immediately.
**Icons appear at the wrong size**
Set `eye_spy.texture_pack_size` in `minetest.conf` to match your texture pack's base resolution (for example, `64x64px` for a 64× pack).
**HUD disappears after a long play session**
This is a Luanti HUD element timing quirk. Eye Spy detects the missing elements and restores them automatically, stare at a block or something which should trigger the hud and it should detect when it isn't showing and fix itself. Doesn't impact performance. You can also trigger an immediate recovery by running `/eye_spy_settings` and clicking Cancel.
**Server performance impact**
Use `/eye_spy_rate default <ms>` to raise the server-wide refresh interval. You can also give specific players a higher interval with `/eye_spy_rate player <name> <ms>` without changing the default for everyone else. Disabling unused info lines per-player also reduces work per tick.
## Contributing
Bug reports, pull requests, and translations are all welcome. Please open an issue or PR on [GitHub](https://github.com/cjgray24/eye_spy).
When reporting a bug, please include:
- Your Luanti version (`minetest --version`)
- Which game or modpack you are using
- Clear steps to reproduce the issue
## License
- **Code**: [LGPL v2.1](LICENSE.txt)
- **Textures, Models, Sounds**: [CC-BY-SA 4.0](LICENSE.txt)
## Credits
Created and maintained by [cjgray24](https://content.luanti.org/users/cjgray24/).
+298
View File
@@ -0,0 +1,298 @@
-- colors.lua
-- Pure color math utilities for eye_spy.
-- No minetest API calls: safe to use from any context.
eye_spy.colors = {}
-- ---------------------------------------------------------------------------
-- Clamp
-- ---------------------------------------------------------------------------
--- Clamp v to [minv, maxv].
local function clamp(v, minv, maxv)
return math.max(minv, math.min(maxv, v))
end
-- ---------------------------------------------------------------------------
-- RGB packing / unpacking
-- ---------------------------------------------------------------------------
--- Split a packed 24-bit integer into its R, G, B byte components.
local function split_rgb(color)
local r = math.floor(color / 65536) % 256
local g = math.floor(color / 256) % 256
local b = color % 256
return r, g, b
end
--- Pack R, G, B byte components into a 24-bit integer.
local function join_rgb(r, g, b)
return (r * 65536) + (g * 256) + b
end
-- ---------------------------------------------------------------------------
-- Luminance helpers
-- ---------------------------------------------------------------------------
--- Linearise a single 0–255 channel value for WCAG relative-luminance maths.
local function channel_luminance(channel)
local c = channel / 255
if c <= 0.03928 then
return c / 12.92
end
return ((c + 0.055) / 1.055) ^ 2.4
end
--- WCAG 2.1 relative luminance of a packed 24-bit RGB integer.
local function color_luminance(color)
local r, g, b = split_rgb(color)
return (0.2126 * channel_luminance(r))
+ (0.7152 * channel_luminance(g))
+ (0.0722 * channel_luminance(b))
end
--- WCAG 2.1 contrast ratio between two packed 24-bit colours.
local function contrast_ratio(color_a, color_b)
local a_l = color_luminance(color_a)
local b_l = color_luminance(color_b)
return (math.max(a_l, b_l) + 0.05) / (math.min(a_l, b_l) + 0.05)
end
-- ---------------------------------------------------------------------------
-- Colour arithmetic
-- ---------------------------------------------------------------------------
--- Multiply every channel of a packed colour by factor and re-pack.
local function scale_color(color, factor)
local r, g, b = split_rgb(color)
r = clamp(math.floor(r * factor), 0, 255)
g = clamp(math.floor(g * factor), 0, 255)
b = clamp(math.floor(b * factor), 0, 255)
return join_rgb(r, g, b)
end
--- Linear interpolation between two packed colours.
-- t = 0 → color_a, t = 1 → color_b.
local function blend_color(color_a, color_b, t)
local factor = clamp(t or 0.5, 0, 1)
local ar, ag, ab = split_rgb(color_a)
local br, bg, bb = split_rgb(color_b)
return join_rgb(
math.floor((ar * (1 - factor)) + (br * factor) + 0.5),
math.floor((ag * (1 - factor)) + (bg * factor) + 0.5),
math.floor((ab * (1 - factor)) + (bb * factor) + 0.5)
)
end
-- ---------------------------------------------------------------------------
-- HSL conversion
-- ---------------------------------------------------------------------------
--- Convert R, G, B bytes to HSL (hue 0–360, saturation 0–1, lightness 0–1).
local function rgb_to_hsl(r, g, b)
local rn, gn, bn = r / 255, g / 255, b / 255
local max_c = math.max(rn, gn, bn)
local min_c = math.min(rn, gn, bn)
local l = (max_c + min_c) * 0.5
if max_c == min_c then
return 0, 0, l
end
local d = max_c - min_c
local s = l > 0.5 and d / (2 - max_c - min_c) or d / (max_c + min_c)
local h
if max_c == rn then
h = ((gn - bn) / d) % 6
elseif max_c == gn then
h = (bn - rn) / d + 2
else
h = (rn - gn) / d + 4
end
return (h / 6) * 360, s, l
end
-- Hoisted helper for hsl_to_rgb — avoids allocating a new closure per call.
-- Converts a hue position p/q at offset t (all normalised 0–1) to a channel value.
local function hue2rgb(p, q, t)
t = t % 1.0
if t < 1 / 6 then return p + (q - p) * 6 * t end
if t < 1 / 2 then return q end
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
return p
end
--- Convert HSL (hue 0–360, saturation 0–1, lightness 0–1) to R, G, B bytes.
local function hsl_to_rgb(h, s, l)
if s <= 0 then
local v = clamp(math.floor(l * 255 + 0.5), 0, 255)
return v, v, v
end
h = h / 360
local q = l < 0.5 and l * (1 + s) or l + s - l * s
local p = 2 * l - q
return clamp(math.floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5), 0, 255),
clamp(math.floor(hue2rgb(p, q, h) * 255 + 0.5), 0, 255),
clamp(math.floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5), 0, 255)
end
-- ---------------------------------------------------------------------------
-- Background luminance (BT.601 perceived brightness, not WCAG)
-- ---------------------------------------------------------------------------
--- Perceived brightness (BT.601) from R, G, B bytes (0–255 range).
-- Returns a value in [0, 255].
local function get_bg_luminance_rgb(r, g, b)
return (r * 0.299) + (g * 0.587) + (b * 0.114)
end
--- Perceived brightness (BT.601) from a 6-character lowercase hex string
-- such as "1a1a1b". Returns a value in [0, 255].
local function get_bg_luminance(rgb_hex)
local r = tonumber(rgb_hex:sub(1, 2), 16) or 26
local g = tonumber(rgb_hex:sub(3, 4), 16) or 26
local b = tonumber(rgb_hex:sub(5, 6), 16) or 27
return get_bg_luminance_rgb(r, g, b)
end
-- ---------------------------------------------------------------------------
-- Foreground selection helpers
-- ---------------------------------------------------------------------------
--- Return whichever of near-black or near-white has the higher contrast ratio
-- against bg_color (packed 24-bit integer).
local function best_monochrome_for_bg(bg_color)
local dark = 0x111111
local light = 0xF0F0F0
if contrast_ratio(light, bg_color) >= contrast_ratio(dark, bg_color) then
return light
end
return dark
end
--- Map a value in [0, 255] to a spectrum colour (red → green → blue → white).
local function spectrum_color(val)
local v = clamp(val or 255, 0, 255)
local r, g, b = 0, 0, 0
if v < 85 then
r = 255 - (v * 3)
g = v * 3
elseif v < 170 then
local x = v - 85
g = 255 - (x * 3)
b = x * 3
elseif v < 230 then
local x = v - 170
r = x * 4
b = 255 - (x * 4)
else
local x = math.floor((v - 230) * (255 / 25))
r, g, b = x, x, x
end
return join_rgb(r, g, b)
end
--- Darken color when the background (given as a hex string) is light,
-- so the colour remains legible.
-- Uses get_bg_luminance_rgb + split_rgb to avoid duplicating the BT.601 formula.
local function adapt_for_bg(color, bg_hex)
if get_bg_luminance(bg_hex) <= 140 then
return color
end
local luminance = get_bg_luminance_rgb(split_rgb(color))
if luminance > 130 then
return scale_color(color, 0.45)
end
return color
end
--- Iteratively blend color toward black or white until it reaches min_ratio
-- contrast against bg_color. Makes up to 5 nudge passes.
local function nudge_contrast(color, bg_color, min_ratio)
local candidate = color
local bg_l = color_luminance(bg_color)
for _ = 1, 5 do
if contrast_ratio(candidate, bg_color) >= (min_ratio or 4.5) then
return candidate
end
local fg_l = color_luminance(candidate)
if fg_l < bg_l then
candidate = blend_color(candidate, 0x000000, 0.32)
else
candidate = blend_color(candidate, 0xFFFFFF, 0.32)
end
end
return candidate
end
--- Adjust color to meet min_ratio contrast against bg_color while
-- preserving hue by blending toward white or black (whichever gives
-- the better contrast) in up to 8 steps.
local function tune_contrast_preserve_hue(color, bg_color, min_ratio)
local required_ratio = min_ratio or 4.0
local candidate = color
if contrast_ratio(candidate, bg_color) >= required_ratio then
return candidate
end
for step = 1, 8 do
if contrast_ratio(candidate, bg_color) >= required_ratio then
return candidate
end
local t = clamp(step * 0.09, 0, 0.70)
local lighter = blend_color(color, 0xFFFFFF, t)
local darker = blend_color(color, 0x000000, t)
if contrast_ratio(lighter, bg_color) >= contrast_ratio(darker, bg_color) then
candidate = lighter
else
candidate = darker
end
end
return candidate
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
eye_spy.colors.clamp = clamp
eye_spy.colors.split_rgb = split_rgb
eye_spy.colors.join_rgb = join_rgb
eye_spy.colors.channel_luminance = channel_luminance
eye_spy.colors.color_luminance = color_luminance
eye_spy.colors.contrast_ratio = contrast_ratio
eye_spy.colors.scale_color = scale_color
eye_spy.colors.blend_color = blend_color
eye_spy.colors.rgb_to_hsl = rgb_to_hsl
eye_spy.colors.hsl_to_rgb = hsl_to_rgb
eye_spy.colors.get_bg_luminance_rgb = get_bg_luminance_rgb
eye_spy.colors.get_bg_luminance = get_bg_luminance
eye_spy.colors.best_monochrome_for_bg = best_monochrome_for_bg
eye_spy.colors.spectrum_color = spectrum_color
eye_spy.colors.adapt_for_bg = adapt_for_bg
eye_spy.colors.nudge_contrast = nudge_contrast
eye_spy.colors.tune_contrast_preserve_hue = tune_contrast_preserve_hue
+403
View File
@@ -0,0 +1,403 @@
--commands
local S = minetest.get_translator(minetest.get_current_modname())
-- ---------------------------------------------------------------------------
-- Privileges
-- ---------------------------------------------------------------------------
minetest.register_privilege("eye_spy_rate", {
description = "Manage Eye Spy HUD refresh rates",
give_to_singleplayer = true,
})
minetest.register_privilege("eye_spy_admin", {
description = "Manage Eye Spy HUD visibility per player (server-side override).",
give_to_singleplayer = true,
})
-- ---------------------------------------------------------------------------
-- Helper: clamp an interval value to the permitted range (mirrors functions.lua)
-- ---------------------------------------------------------------------------
local function clamp_ms(value)
return math.max(50, math.min(5000, tonumber(value) or 250))
end
-- ---------------------------------------------------------------------------
-- /eye_spy_settings — open the settings UI (no priv required, player-only)
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_settings", {
description = S("Open Eye Spy settings and live preview editor."),
func = function(name, _param)
local player = minetest.get_player_by_name(name)
if not player then
return false, S("This command can only be used by a player.")
end
eye_spy.enter_edit_mode(player)
return true, ""
end,
})
-- ---------------------------------------------------------------------------
-- /eye_spy_rate — view/set refresh rate defaults and per-player overrides
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_rate", {
params = "show | default <ms> | player <name> <ms|default>",
description = S("Admin: view/set Eye Spy refresh rate defaults and optional per-player overrides"),
privs = { eye_spy_rate = true },
func = function(name, param)
-- Parse
local args = {}
for token in (param or ""):gmatch("%S+") do
args[#args + 1] = token
end
local action = args[1]
-- show (default)
if not action or action == "" or action == "show" then
local server_default = eye_spy.get_server_default_interval_ms()
local self_effective = eye_spy.get_effective_interval_ms(name)
return true, string.format(
"Eye Spy rate: server default=%dms, your effective=%dms",
server_default,
self_effective
)
end
-- default <ms>
if action == "default" then
local ms = tonumber(args[2])
if not ms then
return false, "Usage: /eye_spy_rate default <ms>"
end
local clamped = clamp_ms(ms)
eye_spy.set_server_default_interval_ms(clamped)
-- Apply to all online players that have no personal override
for _, player in ipairs(minetest.get_connected_players()) do
local player_name = player:get_player_name()
if not eye_spy.get_player_interval_override_ms(player_name) then
eye_spy.apply_player_interval(player)
end
end
return true, string.format("Eye Spy server default refresh set to %dms", clamped)
end
-- player <name> <ms|default>
if action == "player" then
local target_name = args[2]
local value = args[3]
if not target_name or not value then
return false, "Usage: /eye_spy_rate player <name> <ms|default>"
end
if value == "default" then
eye_spy.set_player_interval_override_ms(target_name, nil)
else
local ms = tonumber(value)
if not ms then
return false, "Usage: /eye_spy_rate player <name> <ms|default>"
end
eye_spy.set_player_interval_override_ms(target_name, clamp_ms(ms))
end
local target_player = minetest.get_player_by_name(target_name)
if target_player then
eye_spy.apply_player_interval(target_player)
end
return true, string.format(
"Eye Spy rate for %s is now %dms",
target_name,
eye_spy.get_effective_interval_ms(target_name)
)
end
return false, "Usage: /eye_spy_rate show | default <ms> | player <name> <ms|default>"
end,
})
-- ---------------------------------------------------------------------------
-- /eye_spy_perf — show/reset perf stats and enable/disable metrics collection
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_perf", {
params = "show | reset | on | off",
description = S("Admin: show/reset Eye Spy perf stats and enable/disable metrics"),
privs = { eye_spy_rate = true },
func = function(_name, param)
-- Parse
local action = (param or ""):match("^%s*(%S+)") or "show"
if action == "show" then
return true, eye_spy.perf_report()
end
if action == "reset" then
eye_spy.perf_reset()
return true, "Eye Spy perf stats reset"
end
if action == "on" then
eye_spy.perf_set_enabled(true)
return true, "Eye Spy perf metrics enabled"
end
if action == "off" then
eye_spy.perf_set_enabled(false)
return true, "Eye Spy perf metrics disabled"
end
return false, "Usage: /eye_spy_perf show | reset | on | off"
end,
})
-- ---------------------------------------------------------------------------
-- /eye_spy_toggle — player toggles their own HUD on/off (player-only)
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_toggle", {
description = S("Toggle your Eye Spy HUD on or off."),
func = function(name, _param)
-- Validate: must be an in-game player
local player = minetest.get_player_by_name(name)
if not player then
return false, S("This command can only be used by a player.")
end
local meta = player:get_meta()
local enabled = meta:get_string("es_hud_enabled") ~= "false"
if enabled then
-- Toggling OFF
meta:set_string("es_hud_enabled", "false")
local state = eye_spy.get_player_state(player)
eye_spy.render.hide(state, player)
state.last_render_key = nil
state.cached_target = nil
return true, S("Eye Spy HUD disabled.")
else
-- Toggling ON — save preference regardless of admin block
meta:set_string("es_hud_enabled", "true")
-- Check whether an admin has blocked this player
local admin_disabled =
eye_spy.storage:get_string("player_disabled:" .. name) == "true"
if admin_disabled then
return true, S(
"HUD preference set to enabled, but Eye Spy is currently disabled for you by the server admin."
)
end
-- No admin block — HUD will show on the next step() tick
return true, S("Eye Spy HUD enabled.")
end
end,
})
-- ---------------------------------------------------------------------------
-- /eye_spy_status — shows the player their current HUD status (player-only)
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_status", {
description = S("Show your current Eye Spy HUD status."),
func = function(name, _param)
-- Validate: must be an in-game player
local player = minetest.get_player_by_name(name)
if not player then
return false, S("This command can only be used by a player.")
end
local meta = player:get_meta()
local self_disabled = meta:get_string("es_hud_enabled") == "false"
local admin_disabled = eye_spy.storage:get_string("player_disabled:" .. name) == "true"
local interval_ms = eye_spy.get_effective_interval_ms(name)
local status_line
if not self_disabled and not admin_disabled then
status_line = S("Eye Spy HUD: active")
elseif self_disabled and admin_disabled then
status_line = S("Eye Spy HUD: disabled by you and by the server admin")
elseif self_disabled then
status_line = S("Eye Spy HUD: disabled by you — use /eye_spy_toggle to re-enable")
else
-- admin_disabled only
status_line = S("Eye Spy HUD: disabled by the server admin")
end
return true, string.format("%s\n%s", status_line,
string.format(S("Update rate: %dms"), interval_ms))
end,
})
-- ---------------------------------------------------------------------------
-- /eye_spy_admin — server-side per-player HUD suppression (eye_spy_admin priv)
-- ---------------------------------------------------------------------------
minetest.register_chatcommand("eye_spy_admin", {
params = "enable <player> | disable <player> | show [player]",
description = S("Manage Eye Spy HUD visibility per player (server-side override)."),
privs = { eye_spy_admin = true },
func = function(caller, param)
-- Parse
local args = {}
for token in (param or ""):gmatch("%S+") do
args[#args + 1] = token
end
local action = args[1]
local target_name = args[2]
-- -----------------------------------------------------------------------
-- disable <player>
-- -----------------------------------------------------------------------
if action == "disable" then
if not target_name or target_name == "" then
return false, "Usage: /eye_spy_admin disable <player>"
end
-- Execute
eye_spy.storage:set_string("player_disabled:" .. target_name, "true")
minetest.log("action", string.format(
"[eye_spy] %s disabled Eye Spy HUD for player %s",
caller, target_name
))
local target_player = minetest.get_player_by_name(target_name)
if target_player then
-- Immediately hide the HUD
local state = eye_spy.get_player_state(target_player)
eye_spy.render.hide(state, target_player)
state.last_render_key = nil
state.cached_target = nil
minetest.chat_send_player(
target_name,
S("Eye Spy has been disabled for you by an administrator.")
)
end
return true, string.format(
"Eye Spy disabled for %s.", target_name
)
end
-- -----------------------------------------------------------------------
-- enable <player>
-- -----------------------------------------------------------------------
if action == "enable" then
if not target_name or target_name == "" then
return false, "Usage: /eye_spy_admin enable <player>"
end
-- Execute: clear the admin-disable flag
eye_spy.storage:set_string("player_disabled:" .. target_name, "")
minetest.log("action", string.format(
"[eye_spy] %s re-enabled Eye Spy HUD for player %s",
caller, target_name
))
local target_player = minetest.get_player_by_name(target_name)
if target_player then
-- Let the HUD re-evaluate itself on the next step() tick
eye_spy.invalidate_player(target_player)
minetest.chat_send_player(
target_name,
S("Eye Spy has been re-enabled for you.")
)
end
return true, string.format(
"Eye Spy re-enabled for %s.", target_name
)
end
-- -----------------------------------------------------------------------
-- show [player]
-- -----------------------------------------------------------------------
if not action or action == "" or action == "show" then
if target_name and target_name ~= "" then
-- Status for a specific player (may be offline)
local admin_disabled = eye_spy.storage:get_string(
"player_disabled:" .. target_name
) == "true"
local self_disabled = false
local online_note = "(offline — self-disable state unknown)"
local target_player = minetest.get_player_by_name(target_name)
if target_player then
local meta = target_player:get_meta()
self_disabled = meta:get_string("es_hud_enabled") == "false"
online_note = "(online)"
end
return true, string.format(
"Eye Spy status for %s %s:\n admin-disabled: %s\n self-disabled: %s",
target_name,
online_note,
admin_disabled and "yes" or "no",
target_player and (self_disabled and "yes" or "no") or "unknown"
)
end
-- No specific player — list all online players
local lines = { "Eye Spy status for online players:" }
for _, player in ipairs(minetest.get_connected_players()) do
local pname = player:get_player_name()
local meta = player:get_meta()
local admin_disabled = eye_spy.storage:get_string(
"player_disabled:" .. pname
) == "true"
local self_disabled = meta:get_string("es_hud_enabled") == "false"
local tag
if admin_disabled and self_disabled then
tag = "disabled (admin + self)"
elseif admin_disabled then
tag = "disabled (admin)"
elseif self_disabled then
tag = "disabled (self)"
else
tag = "active"
end
lines[#lines + 1] = string.format(" %s — %s", pname, tag)
end
if #lines == 1 then
lines[#lines + 1] = " (no players online)"
end
return true, table.concat(lines, "\n")
end
return false, "Usage: /eye_spy_admin enable <player> | disable <player> | show [player]"
end,
})
+199
View File
@@ -0,0 +1,199 @@
-- eye_spy/config.lua
-- Loads all mod settings from minetest.settings and exposes them as
-- eye_spy.config. Also handles the one-time migration of the legacy
-- eye_spy.update_interval (seconds) setting into the ms-based storage key
-- used by the interval system defined in functions.lua.
--
-- Load order requirement: runs AFTER eye_spy = {} is declared in functions.lua,
-- so eye_spy, eye_spy.storage, and eye_spy.set_server_default_interval_ms are
-- all guaranteed to be available here.
-- ---------------------------------------------------------------------------
-- Internal helpers
-- ---------------------------------------------------------------------------
-- Map the human-readable texture_pack_size enum value to a pixel count, or
-- nil when the user has not forced a size (i.e. the "None" default).
local TEXTURE_SIZE_MAP = {
["None"] = nil,
["1x1px"] = 1,
["2x2px"] = 2,
["4x4px"] = 4,
["8x8px"] = 8,
["16x16px"] = 16,
["32x32px"] = 32,
["64x64px"] = 64,
["128x128px"] = 128,
["256x256px"] = 256,
["512x512px"] = 512,
["1024x1024px"] = 1024,
}
-- ---------------------------------------------------------------------------
-- Raw setting reads
-- (each variable is local to this file; the canonical values live in
-- eye_spy.config so the rest of the mod always reads from one place)
-- ---------------------------------------------------------------------------
local settings = minetest.settings
-- Texture pack size enum (raw string, used both stored and for derived value)
local texture_pack_size_raw = settings:get("eye_spy.texture_pack_size") or "None"
-- ---------------------------------------------------------------------------
-- eye_spy.config
-- ---------------------------------------------------------------------------
-- Every entry is documented so server admins can understand what each key
-- controls by reading this file alone.
eye_spy.config = {
-- -------------------------------------------------------------------------
-- Entity / liquid display
-- -------------------------------------------------------------------------
-- Whether to show entity info (health, name) when looking at a mob or
-- dropped item. Corresponds to eye_spy.show_entities in settings.
show_entities = settings:get_bool("eye_spy.show_entities") ~= false,
-- Whether liquid-aware targeting is active. When true Eye Spy can scan
-- through liquid blocks and still show what is behind them.
-- Corresponds to eye_spy.show_liquids in settings.
show_liquids = settings:get_bool("eye_spy.show_liquids") or false,
-- -------------------------------------------------------------------------
-- Creative / tool display
-- -------------------------------------------------------------------------
-- When true the HUD always shows "Hand" as the wielded tool while the
-- player is in creative mode, regardless of what is actually held.
-- Corresponds to eye_spy.always_show_hand_in_creative in settings.
always_show_hand_in_creative = settings:get_bool("eye_spy.always_show_hand_in_creative") ~= false,
-- -------------------------------------------------------------------------
-- Texture / icon sizing
-- -------------------------------------------------------------------------
-- The raw enum string chosen by the admin ("None", "16x16px", etc.).
-- Stored here for reference; use forced_texture_size for numeric logic.
texture_pack_size = texture_pack_size_raw,
-- Numeric pixel size derived from texture_pack_size, or nil when no size
-- is forced. When mod_security is false the engine can determine sizes
-- automatically; this override is intended for texture packs that lie
-- about their resolution.
forced_texture_size = TEXTURE_SIZE_MAP[texture_pack_size_raw],
-- Absolute path to the active texture pack directory as reported by the
-- engine. May be nil when no texture pack is in use. Used to resolve
-- icon paths at runtime. Corresponds to texture_path in settings.
texture_pack_path = settings:get("texture_path"),
-- -------------------------------------------------------------------------
-- Security
-- -------------------------------------------------------------------------
-- Mirror of secure.enable_security. When true, certain filesystem
-- operations (e.g. automatic icon-size detection) are restricted by the
-- engine sandbox and Eye Spy falls back to the admin-configured size.
mod_security = settings:get_bool("secure.enable_security") == true,
-- -------------------------------------------------------------------------
-- Update interval (legacy float, seconds)
-- The authoritative runtime value is stored in mod storage as
-- "server_default_interval_ms" and managed by the interval API in
-- functions.lua. This field is retained only for the one-time migration
-- performed at the bottom of this file.
-- -------------------------------------------------------------------------
-- How often (in seconds) the HUD was refreshed under the old single-float
-- scheme. Default 0.25 s = 250 ms.
-- Corresponds to eye_spy.update_interval in settings.
update_interval = tonumber(settings:get("eye_spy.update_interval")) or 0.1,
-- -------------------------------------------------------------------------
-- Performance metrics
-- -------------------------------------------------------------------------
-- Whether the lightweight perf-metrics system starts enabled. Can be
-- toggled at runtime via /eye_spy_perf without changing this setting.
-- Corresponds to eye_spy.enable_perf_metrics in settings.
perf_metrics_enabled = settings:get_bool("eye_spy.enable_perf_metrics") or false,
-- -------------------------------------------------------------------------
-- Growth / plant display
-- -------------------------------------------------------------------------
-- When true Eye Spy shows growth-stage and soil-status lines for nodes
-- that expose supported plant/sapling metadata.
-- Corresponds to eye_spy.show_growth in settings.
show_growth = settings:get_bool("eye_spy.show_growth") ~= false,
-- -------------------------------------------------------------------------
-- Icon display
-- -------------------------------------------------------------------------
-- When true a small icon representing the targeted node or entity is shown
-- in the HUD alongside the text lines.
-- Corresponds to eye_spy.show_icons in settings.
show_icons = settings:get_bool("eye_spy.show_icons") ~= false,
-- -------------------------------------------------------------------------
-- Per-player HUD element defaults (applied on first join)
-- These values are written into player meta the first time a player joins
-- and can be changed per-player at runtime. Changing these settings only
-- affects players who have never joined before (or whose meta was reset).
-- -------------------------------------------------------------------------
-- Show coordinates in the HUD footer.
-- Corresponds to eye_spy.default_show_coords in settings.
default_show_coords = settings:get_bool("eye_spy.default_show_coords") or false,
-- Show the ambient light level at the player's position.
-- Corresponds to eye_spy.default_show_light_level in settings.
default_show_light_level = settings:get_bool("eye_spy.default_show_light_level") ~= false,
-- Show the mob-spawning hint line (indicates whether the light level is
-- low enough for hostile mobs to spawn nearby).
-- Corresponds to eye_spy.default_show_spawn_hint in settings.
default_show_spawn_hint = settings:get_bool("eye_spy.default_show_spawn_hint") or false,
-- Show the liquid-info line when looking at or through a liquid node.
-- Corresponds to eye_spy.default_show_liquid_info in settings.
default_show_liquid_info = settings:get_bool("eye_spy.default_show_liquid_info") ~= false,
-- Show the estimated dig-time line for the targeted node.
-- Corresponds to eye_spy.default_show_dig_time in settings.
default_show_dig_time = settings:get_bool("eye_spy.default_show_dig_time") ~= false,
}
-- ---------------------------------------------------------------------------
-- Legacy update_interval migration
-- ---------------------------------------------------------------------------
-- Older versions of this mod stored the refresh rate as a plain float (in
-- seconds) in the Minetest settings file. The current interval system keeps
-- its authoritative value in mod storage as "server_default_interval_ms".
--
-- If the storage key is not yet set (first run after upgrade, or a clean
-- install where the admin copied over an old minetest.conf), and the legacy
-- setting was given a non-zero value, convert it to milliseconds and write it
-- into storage via the public API so the interval system picks it up.
-- ---------------------------------------------------------------------------
if eye_spy.storage:get_string("server_default_interval_ms") == "" then
local legacy_s = eye_spy.config.update_interval
-- Only migrate an explicitly configured value: skip the bare default (0.25)
-- unless it was intentionally set. We detect "intentionally set" by
-- checking whether the key actually appears in minetest.settings.
local raw_setting = settings:get("eye_spy.update_interval")
if raw_setting ~= nil and tonumber(raw_setting) and tonumber(raw_setting) > 0 then
local ms = math.floor(legacy_s * 1000 + 0.5)
minetest.log("action", "[eye_spy] Migrating legacy update_interval setting ("
.. tostring(legacy_s) .. "s → " .. tostring(ms) .. "ms) to mod storage.")
eye_spy.set_server_default_interval_ms(ms)
end
end
+853
View File
@@ -0,0 +1,853 @@
local S = eye_spy.S
eye_spy.enrichers = {
registry = {},
}
local node_tool_state_by_player = {}
-- Called by init.lua's on_leaveplayer handler to free per-player cached state.
function eye_spy.enrichers.on_player_leave(player_name)
node_tool_state_by_player[player_name] = nil
end
local function add_line(view_model, id, text, color)
view_model.lines[#view_model.lines + 1] = {
id = id,
text = text,
color = color,
}
end
local function meta_bool(meta, key, default_value)
local raw = meta:get_string(key)
if raw == "" then
return default_value == true
end
return raw ~= "false"
end
local function growth_bar(percent)
local slots = 10
local p = math.max(0, math.min(100, percent or 0))
local filled = math.floor((p / 100) * slots + 0.5)
return "[" .. string.rep("|", filled) .. string.rep(" ", slots - filled) .. "] " .. p .. "%"
end
local function liquid_name_from_node(name)
if not name or name == "" then
return S("Unknown")
end
if name:find("water", 1, true) then
return S("Water")
end
if name:find("lava", 1, true) then
return S("Lava")
end
return eye_spy.readable_name_from_id(name) or S("Unknown")
end
local ETHEREAL_SAPLING_SOILS = {
["ethereal:basandra_bush_sapling"] = "ethereal:fiery_dirt",
["ethereal:yellow_tree_sapling"] = "group:soil",
["ethereal:big_tree_sapling"] = "default:dirt_with_grass",
["ethereal:banana_tree_sapling"] = "ethereal:grove_dirt",
["ethereal:frost_tree_sapling"] = "ethereal:crystal_dirt",
["ethereal:mushroom_sapling"] = "ethereal:mushroom_dirt",
["ethereal:mushroom_brown_sapling"] = "ethereal:mushroom_dirt",
["ethereal:palm_sapling"] = "default:sand",
["ethereal:willow_sapling"] = "ethereal:gray_dirt",
["ethereal:redwood_sapling"] = "default:dirt_with_dry_grass",
["ethereal:giant_redwood_sapling"] = "default:dirt_with_dry_grass",
["ethereal:orange_tree_sapling"] = "ethereal:prairie_dirt",
["ethereal:birch_sapling"] = "default:dirt_with_grass",
["ethereal:sakura_sapling"] = "ethereal:bamboo_dirt",
["ethereal:olive_tree_sapling"] = "ethereal:grove_dirt",
["ethereal:lemon_tree_sapling"] = "ethereal:grove_dirt",
}
local function get_current_dig_result(player, nodename)
if not player or not nodename or nodename == "" then
return false, nil
end
local wielded = player:get_wielded_item()
local can_dig, dig_time = eye_spy.tool_can_dig_node(wielded, nodename)
if can_dig then
return true, dig_time
end
local hand_can_dig, hand_dig_time = eye_spy.tool_can_dig_node(ItemStack(""), nodename)
if hand_can_dig then
return true, hand_dig_time
end
return false, nil
end
local function get_below_node(pos)
if not pos then
return nil, nil
end
local below_pos = { x = pos.x, y = pos.y - 1, z = pos.z }
local below = minetest.get_node_or_nil(below_pos)
if not below then
return nil, nil
end
return below, minetest.registered_nodes[below.name]
end
local function is_water_node(node, def)
local name = node and node.name or ""
if name == "" then
return false
end
if name:find("water", 1, true) then
return true
end
local source_name = def and def.liquid_alternative_source or ""
local flowing_name = def and def.liquid_alternative_flowing or ""
return source_name:find("water", 1, true) ~= nil or flowing_name:find("water", 1, true) ~= nil
end
local function has_nearby_water(pos, radius)
if not pos then
return false
end
local scan_radius = math.max(1, tonumber(radius) or 3)
for dx = -scan_radius, scan_radius do
for dz = -scan_radius, scan_radius do
for dy = -1, 1 do
if dx ~= 0 or dy ~= 0 or dz ~= 0 then
local neighbor = minetest.get_node_or_nil({
x = pos.x + dx,
y = pos.y + dy,
z = pos.z + dz,
})
if neighbor then
local neighbor_def = minetest.registered_nodes[neighbor.name]
if is_water_node(neighbor, neighbor_def) then
return true
end
end
end
end
end
end
return false
end
local function is_soil_wet(soil_pos, soil_group)
if (tonumber(soil_group) or 0) >= 3 then
return true
end
return has_nearby_water(soil_pos, 3)
end
local function get_node_label(name)
if not name or name == "" then
return S("Unknown")
end
if name:sub(1, 6) == "group:" then
local group_name = name:sub(7)
if group_name == "soil" then
return S("Soil")
end
return group_name
end
local def = minetest.registered_nodes[name]
local desc = def and def.description
if desc and desc ~= "" then
return desc:match("^[^\n]*") or desc
end
return name
end
local function get_soil_state(pos)
local below, below_def = get_below_node(pos)
local soil_group = below and minetest.get_item_group(below.name, "soil") or 0
local below_pos = pos and { x = pos.x, y = pos.y - 1, z = pos.z } or nil
local is_wet = soil_group > 0 and is_soil_wet(below_pos, soil_group)
return {
node = below,
def = below_def,
soil_group = soil_group,
is_soil = soil_group > 0,
is_wet = is_wet,
}
end
local function get_target_soil_state(target)
local soil_group = minetest.get_item_group(target.name or "", "soil")
if soil_group <= 0 then
return nil
end
return {
node = {
name = target.name,
param1 = target.node and target.node.param1 or 0,
param2 = target.node and target.node.param2 or 0,
},
def = target.def,
soil_group = soil_group,
is_soil = true,
is_wet = is_soil_wet(target.pos, soil_group),
}
end
local function infer_max_stage(base_name, current_stage)
local max_stage = current_stage or 0
for stage = max_stage + 1, 16 do
if minetest.registered_nodes[base_name .. "_" .. stage] then
max_stage = stage
else
break
end
end
if max_stage > 0 then
return max_stage
end
end
local function get_registered_crop_info(base_name)
if farming and farming.registered_plants and farming.registered_plants[base_name] then
local plant = farming.registered_plants[base_name]
return {
system = "farming",
steps = plant.steps,
minlight = plant.minlight or (farming and farming.min_light) or 12,
maxlight = plant.maxlight or (farming and farming.max_light) or 15,
}
end
if x_farming and x_farming.registered_plants then
local short_name = base_name:match(":(.+)$")
local plant = short_name and x_farming.registered_plants[short_name]
if plant then
return {
system = "x_farming",
steps = plant.steps,
minlight = plant.minlight or 13,
maxlight = plant.maxlight or 14,
fertility = plant.fertility,
}
end
end
end
local function soil_matches_fertility(soil_state, fertility)
if not fertility or #fertility == 0 then
return true
end
local below_def = soil_state.def
local groups = below_def and below_def.groups or nil
if not groups then
return false
end
for _, group_name in ipairs(fertility) do
if groups[group_name] and groups[group_name] > 0 then
return true
end
end
return false
end
local function get_growth_light(pos)
if not pos then
return nil
end
return minetest.get_node_light(pos)
end
local function get_growth_info(target)
local def = target.def
local groups = def and def.groups or {}
if groups.sapling then
local meta = minetest.get_meta(target.pos)
return {
kind = "sapling",
age = meta and meta:get_int("age") or 0,
groups = groups,
}
end
local stage_suffix = target.name:match("_(%d+)$")
local base_name = stage_suffix and target.name:gsub("_(%d+)$", "", 1) or nil
local plant = base_name and get_registered_crop_info(base_name) or nil
if not stage_suffix or not (groups.plant or groups.crop or groups.growing or plant) then
return nil
end
local stage = tonumber(stage_suffix) or 0
plant = plant or {}
local max_stage = plant.steps or infer_max_stage(base_name, stage)
if not max_stage or max_stage <= 0 then
return nil
end
return {
kind = "crop",
system = plant.system or "generic",
stage = stage,
max_stage = max_stage,
minlight = plant.minlight or def.minlight,
maxlight = plant.maxlight or def.maxlight,
fertility = plant.fertility,
}
end
local function get_sapling_soil_requirement(target)
local requirement = ETHEREAL_SAPLING_SOILS[target.name]
if requirement then
return requirement
end
return "group:soil"
end
local function soil_matches_requirement(soil_state, requirement)
if not requirement or requirement == "" then
return true
end
if requirement:sub(1, 6) == "group:" then
return minetest.get_item_group(soil_state.node and soil_state.node.name or "", requirement:sub(7)) > 0
end
return soil_state.node and soil_state.node.name == requirement or false
end
local function get_crop_conditions(target, growth_info)
local soil_state = get_soil_state(target.pos)
local light = target.light_level
if light == nil then
light = get_growth_light(target.pos)
end
local minlight = growth_info.minlight
local maxlight = growth_info.maxlight
local fertility_ok = soil_matches_fertility(soil_state, growth_info.fertility)
local soil_ok = soil_state.is_wet and fertility_ok
local light_ok = true
if minlight and light and light < minlight then
light_ok = false
end
if maxlight and light and light > maxlight then
light_ok = false
end
if light == nil and (minlight or maxlight) then
light_ok = false
end
return {
soil = soil_state,
light = light,
soil_ok = soil_ok,
light_ok = light_ok,
fertility_ok = fertility_ok,
}
end
local function get_soil_line(target, growth_info)
local soil_state = get_soil_state(target.pos)
local target_groups = target.def and target.def.groups or {}
if growth_info and growth_info.kind == "crop" then
local suitable = soil_state.is_soil and soil_matches_fertility(soil_state, growth_info.fertility)
local moisture_label
if soil_state.is_wet then
moisture_label = S("Wet")
elseif soil_state.is_soil then
moisture_label = S("Dry")
else
moisture_label = S("No Soil")
end
local color
if suitable and soil_state.is_wet then
color = 0x6BD66B
elseif suitable then
color = 0xFFAA55
else
color = 0xE56363
end
return {
text = S("Soil") .. ": " .. moisture_label .. " | " .. (suitable and S("Suitable") or S("Unsuitable")),
color = color,
}
end
if growth_info and growth_info.kind == "sapling" then
local requirement = get_sapling_soil_requirement(target)
local suitable = soil_matches_requirement(soil_state, requirement)
return {
text = S("Soil") ..
": " .. get_node_label(requirement) .. " | " .. (suitable and S("Suitable") or S("Unsuitable")),
color = suitable and 0x6BD66B or 0xE56363,
}
end
if target_groups.flora or target_groups.attached_node then
return nil
end
local target_soil_state = get_target_soil_state(target)
if target_soil_state then
return {
text = S("Soil") .. ": " .. (target_soil_state.is_wet and S("Wet") or S("Dry")),
color = target_soil_state.is_wet and 0x5555FF or 0xFFAA55,
}
end
end
function eye_spy.enrichers.register(id, spec)
eye_spy.enrichers.registry[#eye_spy.enrichers.registry + 1] = {
id = id,
enabled = spec.enabled,
apply = spec.apply,
}
end
local function ensure_shared_context(context, target)
local shared = context.shared
if shared and shared._target_key == (target.key or target.name) then
return shared
end
shared = {
_target_key = target.key or target.name,
dig_cached = false,
growth_cached = false,
conditions_cached = false,
soil_line_cached = false,
}
function shared:get_dig_result()
if not self.dig_cached then
self.dig_cached = true
self.can_dig, self.dig_time = get_current_dig_result(context.player, target.name)
end
return self.can_dig, self.dig_time
end
function shared:get_growth_info()
if not self.growth_cached then
self.growth_cached = true
self.growth_info = get_growth_info(target)
end
return self.growth_info
end
function shared:get_crop_conditions()
if not self.conditions_cached then
self.conditions_cached = true
local growth_info = self:get_growth_info()
if growth_info and growth_info.kind == "crop" then
self.crop_conditions = get_crop_conditions(target, growth_info)
else
self.crop_conditions = nil
end
end
return self.crop_conditions
end
function shared:get_soil_line()
if not self.soil_line_cached then
self.soil_line_cached = true
self.soil_line = get_soil_line(target, self:get_growth_info())
end
return self.soil_line
end
context.shared = shared
return shared
end
local function get_cached_node_tool(context, target)
local player = context and context.player
if not player then
return nil
end
local shared = ensure_shared_context(context, target)
local player_name = player:get_player_name()
local wielded = player:get_wielded_item()
local wield_name = wielded:get_name() or ""
local target_key = target.key or target.name or ""
local cached = node_tool_state_by_player[player_name]
if cached and cached.target_key == target_key and cached.wield_name == wield_name then
return cached.tool
end
local can_dig = shared:get_dig_result()
local tool_type_hint, matches_tool_type = nil, false
if eye_spy.get_node_tool_hint_and_match then
tool_type_hint, matches_tool_type = eye_spy.get_node_tool_hint_and_match(target.name, wielded)
end
local label = tool_type_hint or S("Unknown")
local color
if can_dig then
color = 0x6BD66B
elseif matches_tool_type then
color = 0xFFAA55
else
color = 0xE56363
end
local tool = {
description = label,
color = color,
}
node_tool_state_by_player[player_name] = {
target_key = target_key,
wield_name = wield_name,
tool = tool,
}
return tool
end
function eye_spy.enrichers.run(context, target, view_model)
ensure_shared_context(context, target)
for _, enricher in ipairs(eye_spy.enrichers.registry) do
if not enricher.enabled or enricher.enabled(context, target, view_model) then
local perf_on = eye_spy.perf and eye_spy.perf.enabled and minetest.get_us_time
local start_us = perf_on and minetest.get_us_time() or 0
local ok, err = pcall(enricher.apply, context, target, view_model)
if not ok then
minetest.log("error", "[eye_spy] enricher '" .. (enricher.id or "?") ..
"' error: " .. tostring(err))
end
if perf_on then
eye_spy.perf_record("enricher_" .. (enricher.id or "unknown"), minetest.get_us_time() - start_us)
end
end
end
end
eye_spy.enrichers.register("node_tool", {
enabled = function(_, target)
return target.kind == "node"
end,
apply = function(context, target, view_model)
local tool = get_cached_node_tool(context, target) or {
description = S("Unknown"),
color = 0xEBB344,
}
view_model.subtitle = tool.description ~= "" and tool.description or S("Unknown")
view_model.subtitle_color = tool.color or 0xEBB344
end,
})
eye_spy.enrichers.register("entity_health", {
enabled = function(_, target)
return target.kind == "entity"
end,
apply = function(context, target, view_model)
if context.meta:get_string("es_hud_health_in") == "Hearts" then
view_model.subtitle = S("Health") ..
": " ..
(eye_spy.round1((target.hp or 0) / 2) or S("Unknown")) ..
"♥/" .. (eye_spy.round1((target.max_hp or 0) / 2) or S("Unknown")) .. "♥"
else
view_model.subtitle = S("Health") ..
": " .. (eye_spy.round1(target.hp) or S("Unknown")) .. "/" .. (eye_spy.round1(target.max_hp) or S("Unknown"))
end
view_model.subtitle_color = 0xFF4040
end,
})
eye_spy.enrichers.register("item_details", {
enabled = function(_, target)
return target.kind == "item"
end,
apply = function(_, target, view_model)
local count = tonumber(target.stack_count) or 1
if count > 1 then
add_line(view_model, "item_count", S("Count") .. ": " .. tostring(count), 0xE8D37A)
end
local wear = tonumber(target.stack_wear) or 0
if wear > 0 then
local remain_pct = math.floor(math.max(0, 1 - (wear / 65535)) * 100 + 0.5)
local wear_color = 0xE56363
if remain_pct >= 60 then
wear_color = 0x6BD66B
elseif remain_pct >= 30 then
wear_color = 0xF0B35E
end
add_line(view_model, "item_wear", S("Durability") .. ": " .. tostring(remain_pct) .. "%", wear_color)
end
if target.item_def_description and target.item_def_description ~= "" then
local lines = {}
for line in target.item_def_description:gmatch("[^\n]+") do
table.insert(lines, line)
end
for i = 2, #lines do
add_line(view_model, "item_def_line_" .. i, lines[i], 0xCCCCCC)
end
end
if target.stack_has_meta then
local detail = target.stack_meta_summary
if detail and detail ~= "" then
add_line(view_model, "item_meta", S("Metadata") .. ": " .. detail, 0x98C9FF)
else
add_line(view_model, "item_meta", S("Metadata"), 0x98C9FF)
end
end
end,
})
eye_spy.enrichers.register("growth", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_growth", eye_spy.config.show_growth)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local growth_info = shared:get_growth_info()
if not growth_info then
return
end
if growth_info.kind == "sapling" then
local age = growth_info.age or 0
if age > 0 then
local percent = math.min(math.floor((age / 20) * 100), 100)
add_line(view_model, "growth", S("Maturity") .. ": " .. growth_bar(percent), 0xAAAAFF)
else
add_line(view_model, "growth", S("Status") .. ": " .. S("Rooting"), 0x888888)
end
return
end
if growth_info.kind == "crop" then
local percent = math.floor(math.min(growth_info.stage / math.max(growth_info.max_stage, 1), 1) * 100)
local conditions = shared:get_crop_conditions() or get_crop_conditions(target, growth_info)
local stalled = growth_info.stage < growth_info.max_stage and
(not conditions.soil_ok or not conditions.light_ok)
local status = ""
if stalled then
local reasons = {}
if not conditions.soil_ok then
if not conditions.soil.is_wet then
-- Soil is dry (and may also be wrong type)
reasons[#reasons + 1] = conditions.soil.is_soil and S("Dry Soil") or S("Bad Soil")
else
-- Soil is wet but wrong fertility type for this crop
reasons[#reasons + 1] = S("Bad Soil")
end
end
if not conditions.light_ok then
reasons[#reasons + 1] = S("Bad Light")
end
status = " (" .. S("Stalled")
if #reasons > 0 then
status = status .. ": " .. table.concat(reasons, ", ")
end
status = status .. ")"
end
add_line(
view_model,
"growth",
S("Growth") .. ": " .. growth_bar(percent) .. status,
stalled and 0xFFAA55 or 0x55FF55
)
end
end,
})
eye_spy.enrichers.register("dig_time", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_dig_time", true)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local diggable, time = shared:get_dig_result()
if not diggable then
add_line(view_model, "dig_time", S("Dig Time") .. ": ∞", 0xA9C5FF)
return
end
local t = tonumber(time) or 0
add_line(view_model, "dig_time", S("Dig Time") .. ": " .. string.format("%.2fs", t), 0xA9C5FF)
end,
})
eye_spy.enrichers.register("soil", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_growth", eye_spy.config.show_growth)
end,
apply = function(context, target, view_model)
local shared = ensure_shared_context(context, target)
local soil_line = shared:get_soil_line()
if not soil_line then
return
end
add_line(view_model, "soil", soil_line.text, soil_line.color)
end,
})
eye_spy.enrichers.register("light_level", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_light_level", true)
end,
apply = function(_, target, view_model)
if target.light_level == nil then
add_line(view_model, "light", S("Light") .. ": " .. S("Unknown"), 0xAAAAAA)
return
end
add_line(view_model, "light", S("Light") .. ": " .. tostring(target.light_level), 0xF2DD88)
end,
})
eye_spy.enrichers.register("spawn_risk", {
enabled = function(context, target)
return target.kind == "node" and meta_bool(context.meta, "es_show_spawn_hint", false)
end,
apply = function(context, target, view_model)
if target.light_level == nil then
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Unknown"), 0xAAAAAA)
return
end
local threshold = context.meta:get_int("es_spawn_safe_light_threshold")
if threshold < 0 or threshold > 15 then
threshold = 8
end
if target.light_level <= threshold then
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Dark Enough"), 0xF08A55)
else
add_line(view_model, "spawn", S("Spawn Risk") .. " (" .. S("Est") .. "): " .. S("Likely Safe"), 0x6BD66B)
end
end,
})
eye_spy.enrichers.register("liquid_info", {
enabled = function(context, target)
if not eye_spy.config.show_liquids then
return false
end
local show_liquid = meta_bool(context.meta, "es_show_liquid_info", true)
if not show_liquid then
return false
end
return target.is_liquid or target.liquid_obstacle ~= nil
end,
apply = function(_, target, view_model)
local liquid_target = target.is_liquid and target or target.liquid_obstacle
if not liquid_target then
return
end
local name = liquid_name_from_node(liquid_target.full_name or liquid_target.name)
local detail
if liquid_target.liquid_type == "source" then
detail = S("Source")
elseif liquid_target.liquid_level then
detail = S("Level") .. ": " .. tostring(liquid_target.liquid_level)
else
detail = S("Flowing")
end
add_line(view_model, "liquid", S("Liquid") .. ": " .. name .. " (" .. detail .. ")", 0x6FB8FF)
end,
})
+676
View File
@@ -0,0 +1,676 @@
--functions
eye_spy = {}
local S = minetest.get_translator(minetest.get_current_modname())
eye_spy.S = S
-- eye_spy.cg is set in init.lua after game detection
------------------------------------------------------------
-- Mod storage (called ONCE at load time)
------------------------------------------------------------
eye_spy.storage = minetest.get_mod_storage()
------------------------------------------------------------
-- Shared runtime tables
------------------------------------------------------------
eye_spy.state = { players = {} }
eye_spy.cache = { tool_info = {} }
eye_spy.edit_mode = {}
eye_spy.pending_preview_updates = {}
eye_spy.pending_layout_refreshes = {}
eye_spy.ui_drafts = {}
------------------------------------------------------------
-- Shared helper functions
------------------------------------------------------------
-- Returns a human-readable name from a node/item id string, or nil on failure.
-- Callers are responsible for providing their own fallback string.
function eye_spy.readable_name_from_id(name)
if not name or name == "" then
return nil
end
local raw = tostring(name)
local stripped = raw:match(":(.+)$") or raw
stripped = stripped
:gsub("_source$", "")
:gsub("_flowing$", "")
:gsub("[_%-%./]+", " ")
:gsub("%s+", " ")
:gsub("^%s+", "")
:gsub("%s+$", "")
if stripped == "" then
return nil
end
return (stripped:gsub("(%a)([%w']*)", function(a, b)
return string.upper(a) .. string.lower(b)
end))
end
-- Returns the mod portion of a namespaced id (e.g. "default" from "default:stone").
function eye_spy.get_modname_from_obj(obj_name)
if not obj_name then return nil end
return obj_name:match("([^:]+)")
end
-- Rounds x to one decimal place, or returns nil if x is falsy.
function eye_spy.round1(x)
if x then
return math.floor(x * 10 + 0.5) / 10
else
return nil
end
end
-- Strips everything from the first texture-modifier caret (^) onwards.
function eye_spy.strip_texture_modifiers(tex)
if type(tex) ~= "string" then
return tex
end
local pos = tex:find("%^")
if not pos then
return tex
end
return tex:sub(1, pos - 1)
end
-- Parses an "R, G, B" string and returns three numbers.
-- Falls back to 26, 26, 27 for any component that cannot be parsed.
function eye_spy.get_rgb(v)
if not v or v == "" then
return 26, 26, 27
end
v = v:gsub("%s", "")
local r, g, b = v:match("^(%d+),(%d+),(%d+)$")
r, g, b = tonumber(r), tonumber(g), tonumber(b)
return r or 26, g or 26, b or 27
end
-- Converts three 0-255 channel values to an uppercase hex string.
function eye_spy.rgb_to_hex(r, g, b)
return string.format("%02X%02X%02X", r, g, b)
end
------------------------------------------------------------
-- Interval system
------------------------------------------------------------
local function clamp_interval_ms(value)
return math.max(50, math.min(5000, tonumber(value) or 250))
end
local function default_interval_for_mode()
if minetest.is_singleplayer and minetest.is_singleplayer() then
return 100
end
return 150
end
function eye_spy.get_server_default_interval_ms()
local raw = eye_spy.storage:get_string("server_default_interval_ms")
if raw and raw ~= "" then
return clamp_interval_ms(raw)
end
return default_interval_for_mode()
end
function eye_spy.set_server_default_interval_ms(interval_ms)
eye_spy.storage:set_string(
"server_default_interval_ms",
tostring(clamp_interval_ms(interval_ms))
)
end
function eye_spy.get_player_interval_override_ms(player_name)
if not player_name or player_name == "" then
return nil
end
local raw = eye_spy.storage:get_string("player_interval_ms:" .. player_name)
if raw == "" then
return nil
end
return clamp_interval_ms(raw)
end
function eye_spy.set_player_interval_override_ms(player_name, interval_ms)
if not player_name or player_name == "" then
return
end
local key = "player_interval_ms:" .. player_name
if interval_ms == nil then
eye_spy.storage:set_string(key, "")
return
end
eye_spy.storage:set_string(key, tostring(clamp_interval_ms(interval_ms)))
end
function eye_spy.get_effective_interval_ms(player_name)
return eye_spy.get_player_interval_override_ms(player_name)
or eye_spy.get_server_default_interval_ms()
end
function eye_spy.apply_player_interval(player)
if not player then return end
local meta = player:get_meta()
local player_name = player:get_player_name()
meta:set_int("es_update_interval_ms", eye_spy.get_effective_interval_ms(player_name))
end
------------------------------------------------------------
-- Admin / player disable system
------------------------------------------------------------
-- Returns true if a server admin has suppressed this player's HUD.
function eye_spy.is_player_server_disabled(player_name)
if not player_name or player_name == "" then return false end
return eye_spy.storage:get_string("player_disabled:" .. player_name) == "true"
end
-- Sets or clears the server-level disable flag for a player.
function eye_spy.set_player_server_disabled(player_name, disabled)
if not player_name or player_name == "" then return end
eye_spy.storage:set_string(
"player_disabled:" .. player_name,
disabled and "true" or ""
)
end
-- Returns true if the player has self-disabled their HUD via meta.
function eye_spy.is_player_self_disabled(player_name)
local player = minetest.get_player_by_name(player_name)
if not player then return false end
return player:get_meta():get_string("es_hud_enabled") == "false"
end
------------------------------------------------------------
-- Player state management
------------------------------------------------------------
-- Returns the runtime state table for a player, creating it if absent.
function eye_spy.get_player_state(player)
local player_name = player:get_player_name()
local state = eye_spy.state.players[player_name]
if not state then
state = {
timer = 0,
edit_mode = false,
last_target_key = nil,
last_render_key = nil,
last_geom_key = nil,
last_probe_pos = nil,
last_probe_dir = nil,
cached_target = nil,
skip_static_acquire = false,
hud_hidden_since_us = nil,
last_recover_try_us = 0,
hud_ids = { lines = {} },
}
eye_spy.state.players[player_name] = state
end
return state
end
-- Clears all cached per-player data and the layout signature.
function eye_spy.invalidate_player(player)
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
state.last_target_key = nil
state.last_render_key = nil
state.last_geom_key = nil
state.cached_target = nil
state.last_probe_pos = nil
state.last_probe_dir = nil
state.skip_static_acquire = false
state.hud_hidden_since_us = nil
state.last_recover_try_us = 0
if eye_spy.render and eye_spy.render.invalidate_layout_sig then
eye_spy.render.invalidate_layout_sig(player_name)
end
end
------------------------------------------------------------
-- Edit mode
------------------------------------------------------------
function eye_spy.enter_edit_mode(player)
if not player then return end
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
if not state.edit_mode then
eye_spy.ensure_player_defaults(player)
state.edit_mode = true
state.timer = 0
eye_spy.edit_mode[player_name] = true
if eye_spy.ui_drafts then
eye_spy.ui_drafts[player_name] = nil
end
minetest.show_formspec(player_name, "eye_spy:ui", eye_spy.get_ui(player, { reopen = false }))
end
end
function eye_spy.exit_edit_mode(player)
if not player then return end
local player_name = player:get_player_name()
local state = eye_spy.get_player_state(player)
if state.edit_mode then
local meta = player:get_meta()
meta:set_string("es_last_obj", "")
meta:set_int("es_last_health", -1)
state.edit_mode = false
state.last_render_key = nil
state.timer = 0
eye_spy.edit_mode[player_name] = false
eye_spy.pending_preview_updates[player_name] = nil
if eye_spy.ui_drafts then
eye_spy.ui_drafts[player_name] = nil
end
minetest.close_formspec(player_name, "eye_spy:ui")
minetest.close_formspec(player_name, "eye_spy:layout_ui")
if eye_spy.render and eye_spy.render.hide then
eye_spy.render.hide(state, player)
end
if eye_spy.render and eye_spy.render.invalidate_layout_sig then
eye_spy.render.invalidate_layout_sig(player_name)
end
end
end
------------------------------------------------------------
-- Player defaults
------------------------------------------------------------
function eye_spy.ensure_player_defaults(player)
local meta = player:get_meta()
if meta:get_string("es_hud_color") == "" then
meta:set_string("es_hud_color", "26, 26, 27")
end
if meta:get_string("es_hud_alignment") == "" then
meta:set_string("es_hud_alignment", "Top-Middle")
end
if meta:get_string("es_hud_health_in") == "" then
meta:set_string("es_hud_health_in", "Points")
end
if meta:get_string("es_auto_text_color") == "" then
meta:set_string("es_auto_text_color", "true")
end
if meta:get_string("es_title_color_val") == "" then
meta:set_int("es_title_color_val", 255)
end
if meta:get_string("es_subtitle_color_val") == "" then
meta:set_int("es_subtitle_color_val", 255)
end
if meta:get_string("es_footer_color_val") == "" then
meta:set_int("es_footer_color_val", 255)
end
if meta:get_string("es_line_color_val") == "" then
meta:set_int("es_line_color_val", 255)
end
if meta:get_string("es_growth_color_val") == "" then
meta:set_int("es_growth_color_val", 110)
end
if meta:get_string("es_soil_color_val") == "" then
meta:set_int("es_soil_color_val", 165)
end
if meta:get_string("es_show_growth") == "" then
meta:set_string("es_show_growth", eye_spy.config.show_growth and "true" or "false")
end
if meta:get_string("es_show_icons") == "" then
meta:set_string("es_show_icons", eye_spy.config.show_icons and "true" or "false")
end
if meta:get_string("es_layout_target") == "" then
meta:set_string("es_layout_target", "Global")
end
if meta:get_string("es_layout_step") == "" then
meta:set_int("es_layout_step", 2)
end
if meta:get_string("es_layout_page") == "" then
meta:set_string("es_layout_page", "Position")
end
if meta:get_string("es_footer_mode") == "" then
meta:set_string("es_footer_mode", "compact")
end
if meta:get_string("es_show_coords") == "" then
meta:set_string("es_show_coords", eye_spy.config.default_show_coords and "true" or "false")
end
if meta:get_string("es_show_light_level") == "" then
meta:set_string("es_show_light_level", eye_spy.config.default_show_light_level and "true" or "false")
end
if meta:get_string("es_show_spawn_hint") == "" then
meta:set_string("es_show_spawn_hint", eye_spy.config.default_show_spawn_hint and "true" or "false")
end
if meta:get_string("es_show_liquid_info") == "" then
meta:set_string("es_show_liquid_info", eye_spy.config.default_show_liquid_info and "true" or "false")
end
if meta:get_string("es_show_dig_time") == "" then
meta:set_string("es_show_dig_time", eye_spy.config.default_show_dig_time and "true" or "false")
end
if meta:get_string("es_spawn_safe_light_threshold") == "" then
meta:set_int("es_spawn_safe_light_threshold", 8)
end
-- Initialise hud-enabled flag so the disable check has a reliable baseline.
if meta:get_string("es_hud_enabled") == "" then
meta:set_string("es_hud_enabled", "true")
end
eye_spy.apply_player_interval(player)
local int_defaults = {
es_global_offset_x = 0,
es_global_offset_y = 0,
es_bg_offset_x = 0,
es_bg_offset_y = 0,
es_icon_offset_x = 0,
es_icon_offset_y = 0,
es_title_offset_x = 0,
es_title_offset_y = 0,
es_subtitle_offset_x = 0,
es_subtitle_offset_y = 0,
es_lines_offset_x = 0,
es_lines_offset_y = 0,
es_footer_offset_x = 0,
es_footer_offset_y = 0,
es_bg_pad_left = 7,
es_bg_pad_right = 0,
es_bg_pad_top = 5,
es_bg_pad_bottom = 0,
es_bg_extra_w = 0,
es_bg_extra_h = 0,
es_bg_scale_x_pct = 100,
es_bg_scale_y_pct = 100,
es_icon_size = 56,
es_title_scale_pct = 100,
es_subtitle_scale_pct = 100,
es_line_scale_pct = 100,
es_footer_scale_pct = 100,
es_first_line_y_adj = 0,
es_line_step_adj = -2, -- matches LAYOUT_META_DEFAULTS in ui.lua
es_footer_nudge_adj = 0,
es_text_base_x_adj = 0,
es_icon_base_x_adj = 0,
es_top_margin_adj = 0,
es_bottom_margin_adj = 0,
}
for key, value in pairs(int_defaults) do
if meta:get_string(key) == "" then
meta:set_int(key, value)
end
end
end
------------------------------------------------------------
-- Core update pipeline
------------------------------------------------------------
local function probe_state_matches(state, player)
if not state.last_probe_pos or not state.last_probe_dir then
return false
end
local pos = player:get_pos()
local dir = player:get_look_dir()
local dx = math.abs((pos.x or 0) - (state.last_probe_pos.x or 0))
local dy = math.abs((pos.y or 0) - (state.last_probe_pos.y or 0))
local dz = math.abs((pos.z or 0) - (state.last_probe_pos.z or 0))
local ddx = math.abs((dir.x or 0) - (state.last_probe_dir.x or 0))
local ddy = math.abs((dir.y or 0) - (state.last_probe_dir.y or 0))
local ddz = math.abs((dir.z or 0) - (state.last_probe_dir.z or 0))
return dx < 0.05 and dy < 0.05 and dz < 0.05
and ddx < 0.01 and ddy < 0.01 and ddz < 0.01
end
local function remember_probe_state(state, player, target)
local pos = player:get_pos()
local dir = player:get_look_dir()
state.last_probe_pos = vector.new(pos)
state.last_probe_dir = vector.new(dir)
state.cached_target = target
end
function eye_spy.update_player(player, opts)
if not player or not eye_spy.target or not eye_spy.render then
return
end
local perf_on = eye_spy.perf.enabled and minetest.get_us_time
local update_start_us = perf_on and minetest.get_us_time() or 0
local options = opts or {}
local state = eye_spy.get_player_state(player)
local target
if not options.force
and not options.preview
and state.cached_target
and probe_state_matches(state, player)
and state.skip_static_acquire
then
target = state.cached_target
state.skip_static_acquire = false
else
local acquire_start_us = perf_on and minetest.get_us_time() or 0
target = eye_spy.target.acquire(player, options)
if perf_on then
eye_spy.perf_record("target_acquire", minetest.get_us_time() - acquire_start_us)
end
if not options.preview then
remember_probe_state(state, player, target)
state.skip_static_acquire = target and target.kind ~= "air"
end
end
if not target or target.kind == "air" then
local now_us = minetest.get_us_time and minetest.get_us_time() or 0
eye_spy.render.hide(state, player)
state.last_target_key = nil
state.last_render_key = nil
state.cached_target = nil
state.skip_static_acquire = false
if now_us > 0 and not state.hud_hidden_since_us then
state.hud_hidden_since_us = now_us
end
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
return
end
local build_start_us = perf_on and minetest.get_us_time() or 0
local view_model = eye_spy.render.build_view_model(player, target, options)
if perf_on then
eye_spy.perf_record("build_view_model", minetest.get_us_time() - build_start_us)
end
if not options.force and view_model.key == state.last_render_key then
state.last_target_key = target.key
eye_spy.perf_inc_counter("render_skip_same_key")
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
return
end
local render_start_us = perf_on and minetest.get_us_time() or 0
eye_spy.render.apply(state, player, view_model)
if perf_on then
eye_spy.perf_record("render_apply", minetest.get_us_time() - render_start_us)
end
state.last_target_key = target.key
state.last_render_key = view_model.key
state.hud_hidden_since_us = nil
state.last_recover_try_us = 0
if perf_on then
eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
end
end
-- Thin wrapper used by the UI and preview system.
function eye_spy.get_hud(player, data)
eye_spy.update_player(player, data or {})
end
------------------------------------------------------------
-- Main step function
------------------------------------------------------------
function eye_spy.step(dtime)
local perf_on = eye_spy.perf.enabled and minetest.get_us_time
local step_start_us = perf_on and minetest.get_us_time() or 0
local now_us = minetest.get_us_time and minetest.get_us_time() or 0
for _, player in ipairs(minetest.get_connected_players()) do
local state = eye_spy.get_player_state(player)
local player_name = player:get_player_name()
local pending_preview = eye_spy.pending_preview_updates[player_name]
-- Service pending edit-mode preview updates.
if state.edit_mode and pending_preview then
local due_at = pending_preview.due_at_us or 0
if pending_preview.force or due_at <= now_us then
eye_spy.pending_preview_updates[player_name] = nil
eye_spy.update_player(player, {
preview = true,
preview_type = pending_preview.preview_type,
force = pending_preview.force == true,
})
end
end
-- Service pending layout-UI refreshes.
local pending_layout_refresh = eye_spy.pending_layout_refreshes[player_name]
if state.edit_mode
and pending_layout_refresh
and pending_layout_refresh.due_at_us <= now_us
then
eye_spy.pending_layout_refreshes[player_name] = nil
minetest.show_formspec(player_name, "eye_spy:layout_ui", eye_spy.get_layout_ui(player))
end
if not state.edit_mode then
local meta = player:get_meta()
-- Check both server-admin and player-self disable flags.
local hud_disabled = eye_spy.is_player_server_disabled(player_name)
or (meta:get_string("es_hud_enabled") == "false")
if hud_disabled then
if state.last_render_key then
eye_spy.render.hide(state, player)
state.last_target_key = nil
state.last_render_key = nil
state.cached_target = nil
state.skip_static_acquire = false
end
state.timer = 0
goto continue_player
end
local interval_ms = meta:get_int("es_update_interval_ms")
local interval = eye_spy.get_server_default_interval_ms() / 1000
if interval_ms > 0 then
interval = interval_ms / 1000
end
interval = math.max(0.05, math.min(5, interval))
state.timer = state.timer + math.min(dtime, interval)
if state.timer >= interval then
state.timer = 0
eye_spy.update_player(player, { preview = false })
end
-- Focus-loss / self-heal: if the HUD appears stuck hidden while we still
-- have a valid cached target, force an occasional refresh attempt.
if state.hud_hidden_since_us
and state.cached_target
and state.cached_target.kind ~= "air"
then
local hidden_for_us = now_us - state.hud_hidden_since_us
local recover_cooldown_us = 900000
if hidden_for_us >= 1200000
and (state.last_recover_try_us or 0) + recover_cooldown_us <= now_us
then
state.last_recover_try_us = now_us
eye_spy.perf_inc_counter("hud_recover_forced_refresh")
eye_spy.update_player(player, { preview = false, force = true })
end
end
end
::continue_player::
end
if perf_on then
eye_spy.perf_record("step_total", minetest.get_us_time() - step_start_us)
end
end
------------------------------------------------------------
-- HUD cleanup
------------------------------------------------------------
function eye_spy.remove_huds(player)
if eye_spy.render then eye_spy.render.remove(player) end
end
+97
View File
@@ -0,0 +1,97 @@
--init
local modpath = minetest.get_modpath("eye_spy")
-- Load modules in dependency order:
-- 1. Core declarations + helpers (defines eye_spy = {})
dofile(modpath .. "/functions.lua")
-- 2. Config (needs eye_spy.storage from functions.lua)
dofile(modpath .. "/config.lua")
-- 3. Performance metrics (needs eye_spy.config)
dofile(modpath .. "/perf.lua")
-- 4. Pure utilities (no minetest runtime deps)
dofile(modpath .. "/colors.lua")
dofile(modpath .. "/tools.lua") -- needs eye_spy.S
-- 5. Target acquisition + enrichment
dofile(modpath .. "/target.lua")
dofile(modpath .. "/enrichers.lua")
-- 6. Rendering
dofile(modpath .. "/render.lua")
-- 7. Registration (entities, nodes)
dofile(modpath .. "/preview.lua")
-- 8. UI + Commands (last, can reference everything above)
dofile(modpath .. "/ui.lua")
dofile(modpath .. "/commands.lua")
-- Detect game type — sets eye_spy.cg used by tools.lua at runtime.
-- tools.lua reads eye_spy.cg inside function bodies, not at load time, so this is fine.
if minetest.get_modpath("default") then
eye_spy.cg = "minetest_game"
elseif minetest.get_modpath("mcl_core") then
eye_spy.cg = "mineclone"
-- Note: voxelibre/mineclonia distinction reserved for future differentiation.
if minetest.get_modpath("vl_legacy") then
eye_spy.cgs = "voxelibre"
else
eye_spy.cgs = "mineclonia"
end
end
-- Player join: initialise per-player state and defaults.
minetest.register_on_joinplayer(function(player)
local meta = player:get_meta()
-- Clear legacy tracking keys.
meta:set_string("es_last_obj", "")
meta:set_int("es_last_health", -1)
-- Apply defaults for any keys not yet set.
eye_spy.ensure_player_defaults(player)
-- Initialise runtime state.
local state = eye_spy.get_player_state(player)
state.timer = 0
state.edit_mode = false
eye_spy.edit_mode[player:get_player_name()] = false
end)
-- Player leave: clean up ALL per-player runtime data.
minetest.register_on_leaveplayer(function(player)
local player_name = player:get_player_name()
-- Runtime state tables.
eye_spy.state.players[player_name] = nil
eye_spy.edit_mode[player_name] = nil
eye_spy.pending_preview_updates[player_name] = nil
eye_spy.pending_layout_refreshes[player_name] = nil
eye_spy.ui_drafts[player_name] = nil
-- Invalidate render layout signature cache.
if eye_spy.render and eye_spy.render.invalidate_layout_sig then
eye_spy.render.invalidate_layout_sig(player_name)
end
-- Enricher tool-hint cache.
if eye_spy.enrichers and eye_spy.enrichers.on_player_leave then
eye_spy.enrichers.on_player_leave(player_name)
end
-- HUD elements are automatically destroyed by the engine when a player leaves,
-- but we must remove our engine references to prevent stale ID usage on rejoin.
eye_spy.remove_huds(player)
end)
-- Main game loop.
minetest.register_globalstep(function(dtime)
eye_spy.step(dtime)
end)
minetest.log("action", "[eye_spy] Mod loaded successfully.")
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Ruka
Pickaxe=Krápáč / Krumpáč
Sword=Meč
Shears=Nůžky
Axe=Sekera
Shovel=Lopata
Unbreakable=Nezničitelné
None=Žádné
Type=Typ
Health=Zdraví
Unknown=Neznámé
Preview=Náhled
Hud Alignment=Zarovnání HUD
Top-Middle=Nahoře-Střed
Top-Right=Nahoře-Vpravo
Top-Left=Nahoře-Vlevo
Middle-Right=Střed-Vpravo
Middle-Left=Střed-Vlevo
Bottom-Right=Dole-Vpravo
Bottom-Left=Dole-Vlevo
Health in=Zdraví v
Points=Body
Hearts=Srdce
Set to Default=Obnovit výchozí
Change Preview to=Změnit náhled na
Node=Uzel
Entity=Entita
Cancel=Zrušit
Apply Changes=Použít změny
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Hand
Pickaxe=Spitzhacke
Sword=Schwert
Shears=Schere
Axe=Axt
Shovel=Schaufel
Unbreakable=Unzerstörbar
None=Keiner
Type=Typ
Health=Gesundheit
Unknown=Unbekannt
Preview=Vorschau
Hud Alignment=Hudausrichtung
Top-Middle=Oben-Mitte
Top-Right=Oben-Rechts
Top-Left=Oben-Links
Middle-Right=Mitte-Rechts
Middle-Left=Mitte-Links
Bottom-Right=Unten-Rechts
Bottom-Left=Unten-Links
Health in=Gesundheit in
Points=Punkten
Hearts=Herzen
Set to Default=auf Standart setzen
Change Preview to=Vorschau ändern zu
Node=Node
Entity=Entity
Cancel=Abbrechen
Apply Changes=Änderungen übernehmen
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Mano
Pickaxe=Pico
Sword=Espada
Shears=Tijeras
Axe=Hacha
Shovel=Pala
Unbreakable=Irrompible
None=Ninguno
Type=Tipo
Health=Salud
Unknown=Desconocido
Preview=Vista previa
Hud Alignment=Alineación del HUD
Top-Middle=Arriba-Centro
Top-Right=Arriba-Derecha
Top-Left=Arriba-Izquierda
Middle-Right=Centro-Derecha
Middle-Left=Centro-Izquierda
Bottom-Right=Abajo-Derecha
Bottom-Left=Abajo-Izquierda
Health in=Salud en
Points=Puntos
Hearts=Corazones
Set to Default=Restablecer valores
Change Preview to=Cambiar vista previa a
Node=Nodo
Entity=Entidad
Cancel=Cancelar
Apply Changes=Aplicar cambios
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Main
Pickaxe=Pioche
Sword=Épée
Shears=Cisailles
Axe=Hache
Shovel=Pelle
Unbreakable=Incassable
None=Aucun
Type=Type
Health=Santé
Unknown=Inconnu
Preview=Aperçu
Hud Alignment=Alignement HUD
Top-Middle=Haut-Centre
Top-Right=Haut-Droite
Top-Left=Haut-Gauche
Middle-Right=Milieu-Droite
Middle-Left=Milieu-Gauche
Bottom-Right=Bas-Droite
Bottom-Left=Bas-Gauche
Health in=Vie en
Points=Points
Hearts=Cœurs
Set to Default=Réinitialiser
Change Preview to=Changer l’aperçu en
Node=Nœud
Entity=Entité
Cancel=Annuler
Apply Changes=Appliquer les modifications
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Mano
Pickaxe=Piccone
Sword=Spada
Shears=Cesoie
Axe=Ascia
Shovel=Pala
Unbreakable=Indistruttibile
None=Nessuno
Type=Tipo
Health=Salute
Unknown=Sconosciuto
Preview=Anteprima
Hud Alignment=Allineamento HUD
Top-Middle=Alto-Centro
Top-Right=Alto-Destra
Top-Left=Alto-Sinistra
Middle-Right=Centro-Destra
Middle-Left=Centro-Sinistra
Bottom-Right=Basso-Destra
Bottom-Left=Basso-Sinistra
Health in=Salute in
Points=Punti
Hearts=Cuori
Set to Default=Ripristina predefiniti
Change Preview to=Cambia anteprima in
Node=Nodo
Entity=Entità
Cancel=Annulla
Apply Changes=Applica modifiche
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Hand
Pickaxe=Houwelen / Pikhouweel
Sword=Zwaard
Shears=Snoeischaar
Axe=Bijl
Shovel=Schop
Unbreakable=Onbreekbaar
None=Geen
Type=Type
Health=Gezondheid
Unknown=Onbekend
Preview=Voorbeeld
Hud Alignment=HUD-uitlijning
Top-Middle=Boven-Midden
Top-Right=Boven-Rechts
Top-Left=Boven-Links
Middle-Right=Midden-Rechts
Middle-Left=Midden-Links
Bottom-Right=Onder-Rechts
Bottom-Left=Onder-Links
Health in=Gezondheid in
Points=Punten
Hearts=Harten
Set to Default=Standaard herstellen
Change Preview to=Voorbeeld wijzigen naar
Node=Node
Entity=Entiteit
Cancel=Annuleren
Apply Changes=Wijzigingen toepassen
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Ręka
Pickaxe=Kilof
Sword=Miecz
Shears=Nożyce
Axe=Siekiera
Shovel=Łopata
Unbreakable=Niezniszczalny
None=Brak
Type=Typ
Health=Zdrowie
Unknown=Nieznane
Preview=Podgląd
Hud Alignment=Wyrównanie HUD
Top-Middle=Góra-Środek
Top-Right=Góra-Prawo
Top-Left=Góra-Lewo
Middle-Right=Środek-Prawo
Middle-Left=Środek-Lewo
Bottom-Right=Dół-Prawo
Bottom-Left=Dół-Lewo
Health in=Zdrowie w
Points=Punkty
Hearts=Serca
Set to Default=Przywróć domyślne
Change Preview to=Zmień podgląd na
Node=Węzeł
Entity=Encja
Cancel=Anuluj
Apply Changes=Zastosuj zmiany
Count=
Durability=
Metadata=
+38
View File
@@ -0,0 +1,38 @@
# textdomain: eye_spy
Hand=Рука
Pickaxe=Кирка
Sword=Меч
Shears=Ножницы
Axe=Топор
Shovel=Лопата
Unbreakable=Неразрушимый
None=Нет
Type=Тип
Health=Здоровье
Unknown=Неизвестно
Preview=Предпросмотр
Hud Alignment=Выравнивание HUD
Top-Middle=Верх-Середина
Top-Right=Верх-Право
Top-Left=Верх-Лево
Middle-Right=Середина-Право
Middle-Left=Середина-Лево
Bottom-Right=Низ-Право
Bottom-Left=Низ-Лево
Health in=Здоровье в
Points=Очки
Hearts=Сердца
Set to Default=Сбросить по умолчанию
Change Preview to=Изменить предпросмотр на
Node=Блок
Entity=Сущность
Cancel=Отмена
Apply Changes=Применить изменения
Count=
Durability=
Metadata=
+91
View File
@@ -0,0 +1,91 @@
# textdomain: eye_spy
+%d more=
Advanced Layout=
Advanced Layout Editor=
Apply Changes=
Auto Text Colors=
Axe=
Back=
Background Color=
Bad Light=
Bad Soil=
Cancel=
Coords=
Count=
Dark Enough=
Default=
Dig Time=
Dry=
Dry Soil=
Durability=
Entity=
Est=
Flowing=
Footer=
Footer Mode=
Growth=
HUD Setup=
Hand=
Health=
Health in=
Hud Alignment=
Icons=
Info=
Info Lines=
Item=
Lava=
Level=
Light=
Light Level=
Likely Safe=
Line=
Liquid=
Liquid Info=
Maturity=
Metadata=
Middle-Left=
Middle-Right=
No Soil=
No controls for this target on this page=
Node=
None=
Pickaxe=
Points=
Preview=
Preview Sample=
Preview Type=
Reset All=
Reset Target=
Rooting=
Selected Target Position=
Set to Default=
Shears=
Shovel=
Soil=
Source=
Spawn Hint=
Spawn Risk=
Spawn Thresh=
Stalled=
Status=
Step=
Suitable=
Sword=
Text Styling=
Tips: use step 1 for fine tuning and drag sliders for live preview=
Title=
Top-Left=
Top-Middle=
Top-Right=
Unbreakable=
Type=
Unknown=
Bottom-Right=
Bottom-Left=
Hearts=
Change Preview to=
Unsuitable=
Use pages to edit layout without crowding the form=
Water=
Wet=
+7
View File
@@ -0,0 +1,7 @@
name = eye_spy
title = Eye Spy
description = Lets you see information about the node/entity/item you are looking at.
optional_depends = default, mcl_core, mcl_tools, mcl_doc
supported_games = minetest_game, mineclone2, mineclonia
min_minetest_version = 5.8.0
author = cjgray24
+223
View File
@@ -0,0 +1,223 @@
-- eye_spy/perf.lua
-- Performance metrics collection module for the eye_spy mod.
-- Extracted from functions.lua; loaded after functions.lua and config.lua.
--
-- Exposes:
-- eye_spy.perf (table) live metrics state
-- eye_spy.perf_set_enabled(b) enable/disable + persist to storage
-- eye_spy.perf_reset() clear all samples and counters
-- eye_spy.perf_inc_counter(k,d) increment a named event counter
-- eye_spy.perf_record(k,us) record a timed sample in microseconds
-- eye_spy.perf_report() return a formatted report string
--
-- All public functions are no-ops when eye_spy.perf.enabled == false.
-- ---------------------------------------------------------------------------
-- Internal constants
-- ---------------------------------------------------------------------------
-- Maximum number of rolling samples kept per metric.
-- Older samples are overwritten once the ring buffer is full.
local PERF_SAMPLE_CAP = 200
-- ---------------------------------------------------------------------------
-- Initialise eye_spy.perf state
-- ---------------------------------------------------------------------------
-- eye_spy and eye_spy.config are guaranteed to exist at this point.
-- Restore the persisted enabled-flag from mod storage if present;
-- otherwise fall back to the minetest.conf value read in config.lua.
do
local persisted = eye_spy.storage:get_string("perf_metrics_enabled")
local enabled
if persisted == "true" then
enabled = true
elseif persisted == "false" then
enabled = false
else
-- No persisted override — use the value from minetest.conf.
enabled = eye_spy.config.perf_metrics_enabled
end
-- Preserve any table that was already created (e.g. by a /lua reload).
eye_spy.perf = eye_spy.perf or {}
eye_spy.perf.enabled = enabled
eye_spy.perf.metrics = eye_spy.perf.metrics or {}
eye_spy.perf.counters = eye_spy.perf.counters or {}
end
-- ---------------------------------------------------------------------------
-- Local helpers
-- ---------------------------------------------------------------------------
--- Compute the 95th-percentile sample value for a metric.
-- Uses only the samples that have actually been filled so far.
-- @param metric A metric entry from eye_spy.perf.metrics.
-- @return The p95 value in microseconds, or 0 if no samples.
local function metric_p95(metric)
local n = metric and metric.sample_filled or 0
if n <= 0 then
return 0
end
-- Copy filled samples into a temporary table for sorting.
local values = {}
for i = 1, n do
values[i] = metric.samples[i] or 0
end
table.sort(values)
local idx = math.max(1, math.ceil(n * 0.95))
return values[idx] or 0
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
--- Enable or disable performance metrics collection and persist the choice.
-- When disabled all other perf functions become no-ops on the next call.
-- @param enabled boolean — true to enable, false/nil to disable.
function eye_spy.perf_set_enabled(enabled)
local value = (enabled == true)
eye_spy.perf.enabled = value
eye_spy.storage:set_string("perf_metrics_enabled", value and "true" or "false")
end
--- Reset all collected metrics and event counters.
-- Useful before starting a fresh measurement session.
function eye_spy.perf_reset()
eye_spy.perf.metrics = {}
eye_spy.perf.counters = {}
end
--- Increment a named event counter by delta (default 1).
-- Silently ignored when metrics are disabled or the key is empty.
-- @param counter_name string Name of the counter to increment.
-- @param delta number Amount to add (default 1; may be negative).
function eye_spy.perf_inc_counter(counter_name, delta)
if not eye_spy.perf.enabled then
return
end
local key = tostring(counter_name or "")
if key == "" then
return
end
local step = tonumber(delta) or 1
if step == 0 then
return
end
local counters = eye_spy.perf.counters
counters[key] = (counters[key] or 0) + step
end
--- Record a timing sample for a named metric.
-- Samples are stored in a fixed-size ring buffer (PERF_SAMPLE_CAP entries).
-- Silently ignored when metrics are disabled or duration is negative.
-- @param metric_name string Name of the metric (e.g. "update_player").
-- @param duration_us number Elapsed time for this sample, in microseconds.
function eye_spy.perf_record(metric_name, duration_us)
if not eye_spy.perf.enabled then
return
end
local us = tonumber(duration_us) or 0
if us < 0 then
return
end
local metrics = eye_spy.perf.metrics
local metric = metrics[metric_name]
-- Lazily create the metric entry on first use.
if not metric then
metric = {
count = 0, -- total number of samples recorded
total_us = 0, -- cumulative sum of all sample durations
max_us = 0, -- single worst-case duration seen
samples = {}, -- ring buffer of recent raw durations
sample_index = 1, -- write head for the ring buffer (1-based)
sample_filled = 0, -- how many slots are populated (<= PERF_SAMPLE_CAP)
}
metrics[metric_name] = metric
end
-- Update aggregate statistics.
metric.count = metric.count + 1
metric.total_us = metric.total_us + us
metric.max_us = math.max(metric.max_us, us)
-- Write into ring buffer and advance the write head.
metric.samples[metric.sample_index] = us
metric.sample_index = metric.sample_index + 1
if metric.sample_index > PERF_SAMPLE_CAP then
metric.sample_index = 1
end
if metric.sample_filled < PERF_SAMPLE_CAP then
metric.sample_filled = metric.sample_filled + 1
end
end
--- Build and return a human-readable performance report string.
-- Lists every metric that has at least one sample, sorted alphabetically,
-- followed by every non-zero event counter.
-- Returns a short placeholder message if no data has been collected yet.
-- @return string Multi-line report.
function eye_spy.perf_report()
-- Collect metric keys that have real data.
local keys = {}
for key, metric in pairs(eye_spy.perf.metrics) do
if metric and metric.count > 0 then
keys[#keys + 1] = key
end
end
table.sort(keys)
-- Collect counter keys that are non-zero.
local counter_keys = {}
for key, value in pairs(eye_spy.perf.counters or {}) do
if (tonumber(value) or 0) ~= 0 then
counter_keys[#counter_keys + 1] = key
end
end
table.sort(counter_keys)
if #keys == 0 and #counter_keys == 0 then
return "No Eye Spy perf samples yet"
end
local lines = { "Eye Spy perf (avg/p95/max in ms):" }
for _, key in ipairs(keys) do
local metric = eye_spy.perf.metrics[key]
local avg_ms = (metric.total_us / math.max(metric.count, 1)) / 1000
local p95_ms = metric_p95(metric) / 1000
local max_ms = metric.max_us / 1000
lines[#lines + 1] = string.format(
"%s: avg=%.3f p95=%.3f max=%.3f n=%d",
key, avg_ms, p95_ms, max_ms, metric.count
)
end
if #counter_keys > 0 then
lines[#lines + 1] = "Eye Spy perf counters:"
for _, key in ipairs(counter_keys) do
lines[#lines + 1] = string.format(
"%s: n=%d",
key, eye_spy.perf.counters[key] or 0
)
end
end
return table.concat(lines, "\n")
end
+28
View File
@@ -0,0 +1,28 @@
--preview
local S = minetest.get_translator(minetest.get_current_modname())
local preview_texture = "eye_spy_preview_texture2.png"
minetest.register_node("eye_spy:preview_node", {
description = S("Preview") .. " " .. S("Node"),
tiles = { preview_texture },
groups = { cracky = 3, level = 1, not_in_creative_inventory = 1 },
drop = "",
})
minetest.register_entity("eye_spy:preview_entity", {
initial_properties = {
description = S("Preview") .. " " .. S("Entity"),
physical = false,
collide_with_objects = false,
collisionbox = { -0.0, -0.0, -0.0, 0.0, 0.0, 0.0 },
visual = "cube",
textures = {
preview_texture, preview_texture, preview_texture,
preview_texture, preview_texture, preview_texture,
},
health = 8,
max_hp = 10,
},
})
+864
View File
@@ -0,0 +1,864 @@
local S = eye_spy.S
eye_spy.render = {}
-- Bump this string whenever the HUD layout math, element set, or render key
-- format changes in a way that would make old cached render keys produce a
-- wrong or stale layout for existing players.
local RENDER_LAYOUT_REV = "advanced-layout-controls-v2-padding-fix"
local ICON_TEXTURE_SIZE = 32
local ICON_DISPLAY_SIZE = 56
local ENTITY_ICON_TEXTURE = "heart.png"
local BASE_LAYOUT_LINE_ROWS = 6
local MAX_LAYOUT_SCALE_FACTOR = 3
local FIXED_LINE_ROW_BY_ID = {
dig_time = 1,
light = 2,
soil = 3,
growth = 4,
spawn = 5,
liquid = 6,
}
local FIXED_LINE_ORDER = {
"dig_time",
"light",
"soil",
"growth",
"spawn",
"liquid",
}
local layout_key_names = {
"es_global_offset_x",
"es_global_offset_y",
"es_bg_offset_x",
"es_bg_offset_y",
"es_icon_offset_x",
"es_icon_offset_y",
"es_title_offset_x",
"es_title_offset_y",
"es_subtitle_offset_x",
"es_subtitle_offset_y",
"es_lines_offset_x",
"es_lines_offset_y",
"es_footer_offset_x",
"es_footer_offset_y",
"es_bg_pad_left",
"es_bg_pad_right",
"es_bg_pad_top",
"es_bg_pad_bottom",
"es_bg_extra_w",
"es_bg_extra_h",
"es_bg_scale_x_pct",
"es_bg_scale_y_pct",
"es_icon_size",
"es_title_scale_pct",
"es_subtitle_scale_pct",
"es_line_scale_pct",
"es_footer_scale_pct",
"es_first_line_y_adj",
"es_line_step_adj",
"es_footer_nudge_adj",
"es_text_base_x_adj",
"es_icon_base_x_adj",
"es_top_margin_adj",
"es_bottom_margin_adj",
}
local layouts = {
["Top-Middle"] = { pos = { x = 0.5, y = 0 }, x_mode = "center", y_mode = "top" },
["Top-Right"] = { pos = { x = 1, y = 0 }, x_mode = "right", y_mode = "top" },
["Top-Left"] = { pos = { x = 0, y = 0 }, x_mode = "left", y_mode = "top" },
["Middle-Right"] = { pos = { x = 1, y = 0.5 }, x_mode = "right", y_mode = "middle" },
["Middle-Left"] = { pos = { x = 0, y = 0.5 }, x_mode = "left", y_mode = "middle" },
["Bottom-Right"] = { pos = { x = 1, y = 1 }, x_mode = "right", y_mode = "bottom" },
["Bottom-Left"] = { pos = { x = 0, y = 1 }, x_mode = "left", y_mode = "bottom" },
}
-- ---------------------------------------------------------------------------
-- Color math — aliased from colors.lua for local-variable call performance.
-- ---------------------------------------------------------------------------
local clamp = eye_spy.colors.clamp
local split_rgb = eye_spy.colors.split_rgb
local join_rgb = eye_spy.colors.join_rgb
local channel_luminance = eye_spy.colors.channel_luminance
local color_luminance = eye_spy.colors.color_luminance
local contrast_ratio = eye_spy.colors.contrast_ratio
local scale_color = eye_spy.colors.scale_color
local blend_color = eye_spy.colors.blend_color
local rgb_to_hsl = eye_spy.colors.rgb_to_hsl
local hsl_to_rgb = eye_spy.colors.hsl_to_rgb
local get_bg_luminance_rgb = eye_spy.colors.get_bg_luminance_rgb
local get_bg_luminance = eye_spy.colors.get_bg_luminance
local best_monochrome_for_bg = eye_spy.colors.best_monochrome_for_bg
local spectrum_color = eye_spy.colors.spectrum_color
local adapt_for_bg = eye_spy.colors.adapt_for_bg
local nudge_contrast = eye_spy.colors.nudge_contrast
local tune_contrast_preserve_hue = eye_spy.colors.tune_contrast_preserve_hue
local function build_layout_lines(view_model)
local fixed_slots = {}
local append_lines = {}
for _, line in ipairs(view_model.lines or {}) do
local fixed_row = line.id and FIXED_LINE_ROW_BY_ID[line.id] or nil
if fixed_row and fixed_slots[line.id] == nil then
fixed_slots[line.id] = line
else
append_lines[#append_lines + 1] = line
end
end
local ordered_lines = {}
for _, line_id in ipairs(FIXED_LINE_ORDER) do
if fixed_slots[line_id] then
ordered_lines[#ordered_lines + 1] = fixed_slots[line_id]
end
end
for _, line in ipairs(append_lines) do
ordered_lines[#ordered_lines + 1] = line
end
local total_rows = #ordered_lines
local required_rows = math.max(1, total_rows)
local max_rows = BASE_LAYOUT_LINE_ROWS * MAX_LAYOUT_SCALE_FACTOR
local visible_rows = math.min(required_rows, max_rows)
local layout_lines = {}
for row = 1, visible_rows do
layout_lines[row] = ordered_lines[row]
end
local overflow_count = math.max(0, total_rows - visible_rows)
if overflow_count > 0 and visible_rows > 0 then
layout_lines[visible_rows] = {
id = "overflow",
text = string.format(S("+%d more"), overflow_count),
color = 0xAAAAAA,
}
end
return layout_lines, visible_rows
end
local function estimate_panel_width(view_model, layout_lines)
local max_len = math.max(#(view_model.title or ""), #(view_model.subtitle or ""), #(view_model.footer or ""))
for _, line in ipairs(layout_lines or view_model.lines or {}) do
if line then
max_len = math.max(max_len, #(line.text or ""))
end
end
local baseline_width = view_model.icon and 468 or 420
local baseline_char_budget = view_model.icon and 176 or 208
local grow_chars = math.max(0, max_len - baseline_char_budget)
local required_width = baseline_width + math.floor(grow_chars * 6)
return clamp(required_width, baseline_width, baseline_width * MAX_LAYOUT_SCALE_FACTOR)
end
local function has_line(view_model, line_id)
for _, line in ipairs(view_model.lines) do
if line.id == line_id then
return true
end
end
return false
end
-- Hue offsets (degrees) spread each line type around the complementary of
-- the background hue so they remain visually distinct from each other.
local LINE_HUE_OFFSETS = {
growth = 0,
soil = 30,
dig_time = 55,
light = 85,
spawn = 45,
liquid = -50,
}
local auto_line_color_cache = {}
local auto_line_color_cache_size = 0
local function get_auto_line_accent(line_id, bg_hex, bg_color, base_text, bg_luminance)
local cache_key = table.concat({ line_id or "", bg_hex or "", tostring(base_text) }, "|")
local cached = auto_line_color_cache[cache_key]
if cached then
return cached
end
local bg_r, bg_g, bg_b = split_rgb(bg_color)
local bg_h, bg_s, bg_l = rgb_to_hsl(bg_r, bg_g, bg_b)
-- Dynamic accent: complementary hue + per-line offset, lightness inversely
-- proportional to bg lightness so it remains readable on any saturated bg
-- without collapsing to a monochrome colour.
local hue_off = LINE_HUE_OFFSETS[line_id] or 0
local accent_h = (bg_h + 180 + hue_off) % 360
local accent_s = clamp(0.60 + bg_s * 0.22, 0.48, 0.88)
local accent_l = clamp(0.76 - bg_l * 0.52, 0.20, 0.82)
local dr, dg, db = hsl_to_rgb(accent_h, accent_s, accent_l)
local dynamic = join_rgb(dr, dg, db)
-- Fixed palettes are still used as the starting point for near-neutral
-- backgrounds where the bg hue is undefined or very weak.
local dark_palette = {
growth = 0x8BE68B,
soil = 0xE7C882,
dig_time = 0xAFC9FF,
light = 0xF2DE98,
spawn = 0xF2B189,
liquid = 0x8CC8FF,
}
local light_palette = {
growth = 0x2D7A35,
soil = 0x7E5A1F,
dig_time = 0x345C98,
light = 0x725C1D,
spawn = 0x94442A,
liquid = 0x1E5E9A,
}
local lightness_t = clamp((bg_l - 0.35) / 0.30, 0, 1)
local dark_fixed = dark_palette[line_id] or dynamic
local light_fixed = light_palette[line_id] or dynamic
local fixed = blend_color(dark_fixed, light_fixed, lightness_t)
-- Blend: saturated bg → trust dynamic complementary; neutral bg → fixed.
local accent = blend_color(fixed, dynamic, clamp(bg_s * 1.3, 0, 1))
-- Contrast pass without hard-snapping to monochrome fallback.
local result = tune_contrast_preserve_hue(accent, bg_color, 3.6)
result = nudge_contrast(result, bg_color, 3.8)
if auto_line_color_cache_size > 512 then
auto_line_color_cache = {}
auto_line_color_cache_size = 0
end
if not auto_line_color_cache[cache_key] then
auto_line_color_cache_size = auto_line_color_cache_size + 1
end
auto_line_color_cache[cache_key] = result
return result
end
local function adjust_semantic_line_color(color, bg_color)
return nudge_contrast(
tune_contrast_preserve_hue(color, bg_color, 3.6),
bg_color,
3.8
)
end
local function get_line_color_for_manual(meta, line_id)
if line_id == "growth" then
return spectrum_color(meta:get_int("es_growth_color_val"))
end
if line_id == "soil" then
return spectrum_color(meta:get_int("es_soil_color_val"))
end
return spectrum_color(meta:get_int("es_line_color_val"))
end
local function position_text(pos)
if not pos then
return "0, 0, 0"
end
return string.format("%s, %s, %s", eye_spy.round1(pos.x) or 0, eye_spy.round1(pos.y) or 0, eye_spy.round1(pos.z) or 0)
end
local function texture_text(target)
if target and target.kind == "entity" then
return ENTITY_ICON_TEXTURE .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE
end
local texture = target.texture or "eye_spy_default_texture.png"
if type(texture) == "table" then
texture = texture.name or texture.image or "eye_spy_default_texture.png"
end
if type(texture) ~= "string" or texture == "" then
texture = "eye_spy_default_texture.png"
end
texture = eye_spy.strip_texture_modifiers(texture)
if target.color then
texture = texture .. "^[multiply:" .. target.color
elseif target.palette then
texture = texture .. "^[multiply:#7CBD6B"
end
-- Normalize every icon texture to the same source size so HUD icon size is consistent.
texture = texture .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE
return texture
end
-- Per-player layout signature cache. The 35-key meta scan only needs to run
-- when the player is actively changing layout values in the editor. All other
-- updates (normal play, colour slider dragging) reuse the cached string.
local player_layout_sigs = {}
local function get_player_draft(player)
if not player or not eye_spy.ui_drafts then
return nil
end
return eye_spy.ui_drafts[player:get_player_name()]
end
local function get_effective_string(meta, draft, key)
if draft and draft[key] ~= nil then
return tostring(draft[key])
end
return meta:get_string(key)
end
local function get_effective_int(meta, draft, key)
if draft and draft[key] ~= nil then
return tonumber(draft[key]) or 0
end
return meta:get_int(key)
end
function eye_spy.render.invalidate_layout_sig(player_name)
player_layout_sigs[player_name] = nil
end
local function get_layout_signature(meta, player_name)
if player_name and player_layout_sigs[player_name] then
return player_layout_sigs[player_name]
end
local parts = {}
for _, key in ipairs(layout_key_names) do
parts[#parts + 1] = tostring(meta:get_int(key))
end
local sig = table.concat(parts, ",")
if player_name then
player_layout_sigs[player_name] = sig
end
return sig
end
local function get_layout_signature_from_values(meta, draft)
local parts = {}
for _, key in ipairs(layout_key_names) do
parts[#parts + 1] = tostring(get_effective_int(meta, draft, key))
end
return table.concat(parts, ",")
end
local function build_key(view_model)
local parts = {
RENDER_LAYOUT_REV,
view_model.layout_signature or "",
view_model.layout,
view_model.bg_color,
view_model.title,
tostring(view_model.title_color),
view_model.subtitle,
tostring(view_model.subtitle_color),
view_model.footer,
tostring(view_model.footer_color),
view_model.icon and view_model.icon.text or "",
tostring(#view_model.lines),
}
for _, line in ipairs(view_model.lines) do
parts[#parts + 1] = line.id
parts[#parts + 1] = line.text
parts[#parts + 1] = tostring(line.color)
end
return table.concat(parts, "|")
end
local function hud_elem_type(def)
return def and (def.type or def.hud_elem_type)
end
local function ensure_hud_slot(player, hud_ids, key, expected_type, spec)
local id = hud_ids[key]
if id then
local def = player:hud_get(id)
if not def or hud_elem_type(def) ~= expected_type then
if def then
player:hud_remove(id)
end
hud_ids[key] = nil
id = nil
end
end
if not id then
hud_ids[key] = player:hud_add(spec)
end
end
function eye_spy.render.ensure_hud(state, player)
local hud_ids = state.hud_ids
ensure_hud_slot(player, hud_ids, "bg", "image", {
type = "image",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
scale = { x = 400, y = 82 },
alignment = { x = 0, y = 0 },
z_index = 1,
})
ensure_hud_slot(player, hud_ids, "icon", "image", {
type = "image",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
scale = { x = 3.5, y = 3.5 },
alignment = { x = 0, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "title", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "subtitle", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xEBB344,
alignment = { x = 1, y = 0 },
z_index = 2,
})
ensure_hud_slot(player, hud_ids, "footer", "text", {
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0x4343F0,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
function eye_spy.render.ensure_line_slots(state, player, count)
local line_ids = state.hud_ids.lines
for i = 1, #line_ids do
local id = line_ids[i]
local def = id and player:hud_get(id)
if not def or hud_elem_type(def) ~= "text" then
if def then
player:hud_remove(id)
end
line_ids[i] = player:hud_add({
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
end
while #line_ids < count do
line_ids[#line_ids + 1] = player:hud_add({
type = "text",
position = { x = 0.5, y = 0 },
offset = { x = 0, y = 0 },
text = "",
number = 0xFFFFFF,
alignment = { x = 1, y = 0 },
z_index = 2,
})
end
end
function eye_spy.render.hide(state, player)
if not state or not state.hud_ids.bg then
return
end
local hud_ids = state.hud_ids
player:hud_change(hud_ids.bg, "text", "")
player:hud_change(hud_ids.icon, "text", "")
player:hud_change(hud_ids.title, "text", "")
player:hud_change(hud_ids.subtitle, "text", "")
player:hud_change(hud_ids.footer, "text", "")
for _, line_id in ipairs(hud_ids.lines) do
player:hud_change(line_id, "text", "")
end
end
function eye_spy.render.remove(player)
local state = eye_spy.get_player_state(player)
local hud_ids = state.hud_ids
for key, value in pairs(hud_ids) do
if key == "lines" then
for _, line_id in ipairs(value) do
if line_id and player:hud_get(line_id) then
player:hud_remove(line_id)
end
end
elseif value and player:hud_get(value) then
player:hud_remove(value)
end
end
state.hud_ids = { lines = {} }
state.last_render_key = nil
state.last_geom_key = nil
end
function eye_spy.render.build_view_model(player, target, opts)
local perf_on = eye_spy.perf and eye_spy.perf.enabled and minetest.get_us_time
local phase_start_us = perf_on and minetest.get_us_time() or 0
local meta = player:get_meta()
local options = opts or {}
local draft = get_player_draft(player)
local effective_meta = meta
if draft then
effective_meta = {
get_string = function(_, key)
return get_effective_string(meta, draft, key)
end,
get_int = function(_, key)
return get_effective_int(meta, draft, key)
end,
}
end
local r, g, b = eye_spy.get_rgb(get_effective_string(meta, draft, "es_hud_color"))
local bg_luminance = get_bg_luminance_rgb(r or 26, g or 26, b or 27)
local bg_color_int = join_rgb(r or 26, g or 26, b or 27)
local auto_text = get_effective_string(meta, draft, "es_auto_text_color") ~= "false"
local footer_mode = get_effective_string(meta, draft, "es_footer_mode")
local show_coords = get_effective_string(meta, draft, "es_show_coords") ~= "false"
local footer_text
local coords_text = show_coords and (" " .. position_text(target.pos)) or ""
if footer_mode == "advanced" then
local full_name = target.full_name or target.name or S("Unknown")
footer_text = "[" .. full_name .. "] [" .. (target.modname or S("Unknown")) .. "]" .. coords_text
else
footer_text = "[" .. (target.modname or S("Unknown")) .. "]" .. coords_text
end
local view_model = {
visible = target.kind ~= "air",
layout = get_effective_string(meta, draft, "es_hud_alignment") ~= "" and
get_effective_string(meta, draft, "es_hud_alignment") or "Top-Middle",
bg_color = eye_spy.rgb_to_hex(r or 26, g or 26, b or 27),
title = target.description or target.name or S("Unknown"),
title_color = 0xFFFFFF,
subtitle = target.kind == "entity" and S("Entity") or (target.kind == "item" and S("Item") or S("Node")),
subtitle_color = 0xF2D23C,
footer = footer_text,
footer_color = 0x4C7EE8,
lines = {},
layout_signature = draft and get_layout_signature_from_values(meta, draft) or
get_layout_signature(meta, player:get_player_name()),
}
if get_effective_string(meta, draft, "es_show_icons") ~= "false" then
local icon_size = clamp(get_effective_int(meta, draft, "es_icon_size"), 16, 128)
if icon_size <= 0 then
icon_size = ICON_DISPLAY_SIZE
end
view_model.icon = {
text = texture_text(target),
scale = { x = icon_size / ICON_TEXTURE_SIZE, y = icon_size / ICON_TEXTURE_SIZE },
}
end
eye_spy.enrichers.run({ player = player, meta = effective_meta, preview = options.preview }, target, view_model)
if perf_on then
eye_spy.perf_record("build_vm_enrichers", minetest.get_us_time() - phase_start_us)
phase_start_us = minetest.get_us_time()
end
local show_growth = get_effective_string(meta, draft, "es_show_growth") ~= "false"
if options.preview and target.kind == "node" then
if show_growth and not has_line(view_model, "growth") then
view_model.lines[#view_model.lines + 1] = {
id = "growth",
text = S("Growth") .. ": " .. S("Preview Sample"),
color = 0x55FF55,
}
end
if show_growth and not has_line(view_model, "soil") then
view_model.lines[#view_model.lines + 1] = {
id = "soil",
text = S("Soil") .. ": " .. S("Preview Sample"),
color = 0xD8B46A,
}
end
end
if auto_text then
local base_text = best_monochrome_for_bg(bg_color_int)
view_model.title_color = base_text
-- Subtitle/footer: preserve semantic colour (amber for tool, red for health,
-- blue for footer) and nudge toward readable contrast without snapping to
-- a monochrome fallback.
view_model.subtitle_color = nudge_contrast(
tune_contrast_preserve_hue(view_model.subtitle_color, bg_color_int, 3.6),
bg_color_int, 3.8
)
view_model.footer_color = nudge_contrast(
tune_contrast_preserve_hue(view_model.footer_color, bg_color_int, 3.6),
bg_color_int, 3.8
)
for _, line in ipairs(view_model.lines) do
if line.id == "growth" or line.id == "soil" then
line.color = adjust_semantic_line_color(line.color, bg_color_int)
else
line.color = get_auto_line_accent(line.id, view_model.bg_color, bg_color_int, base_text, bg_luminance)
end
end
else
view_model.title_color = spectrum_color(get_effective_int(meta, draft, "es_title_color_val"))
view_model.subtitle_color = spectrum_color(get_effective_int(meta, draft, "es_subtitle_color_val"))
view_model.footer_color = spectrum_color(get_effective_int(meta, draft, "es_footer_color_val"))
for _, line in ipairs(view_model.lines) do
if line.id == "growth" or line.id == "soil" then
line.color = adjust_semantic_line_color(line.color, bg_color_int)
else
line.color = spectrum_color(get_effective_int(meta, draft, "es_line_color_val"))
end
end
end
if perf_on then
eye_spy.perf_record("build_vm_color_pass", minetest.get_us_time() - phase_start_us)
phase_start_us = minetest.get_us_time()
end
view_model.key = build_key(view_model)
if perf_on then
eye_spy.perf_record("build_vm_key_build", minetest.get_us_time() - phase_start_us)
end
return view_model
end
function eye_spy.render.apply(state, player, view_model)
local layout_lines, visible_line_rows = build_layout_lines(view_model)
eye_spy.render.ensure_hud(state, player)
eye_spy.render.ensure_line_slots(state, player, visible_line_rows)
local meta = player:get_meta()
local draft = get_player_draft(player)
local layout = layouts[view_model.layout] or layouts["Top-Middle"]
local line_count = visible_line_rows
local global_offset_x = get_effective_int(meta, draft, "es_global_offset_x")
local global_offset_y = get_effective_int(meta, draft, "es_global_offset_y")
local bg_offset_x = get_effective_int(meta, draft, "es_bg_offset_x")
local bg_offset_y = get_effective_int(meta, draft, "es_bg_offset_y")
local icon_offset_x = get_effective_int(meta, draft, "es_icon_offset_x")
local icon_offset_y = get_effective_int(meta, draft, "es_icon_offset_y")
local title_offset_x = get_effective_int(meta, draft, "es_title_offset_x")
local title_offset_y = get_effective_int(meta, draft, "es_title_offset_y")
local subtitle_offset_x = get_effective_int(meta, draft, "es_subtitle_offset_x")
local subtitle_offset_y = get_effective_int(meta, draft, "es_subtitle_offset_y")
local lines_offset_x = get_effective_int(meta, draft, "es_lines_offset_x")
local lines_offset_y = get_effective_int(meta, draft, "es_lines_offset_y")
local footer_offset_x = get_effective_int(meta, draft, "es_footer_offset_x")
local footer_offset_y = get_effective_int(meta, draft, "es_footer_offset_y")
local bg_pad_left = get_effective_int(meta, draft, "es_bg_pad_left")
local bg_pad_right = get_effective_int(meta, draft, "es_bg_pad_right")
local bg_pad_top = get_effective_int(meta, draft, "es_bg_pad_top")
local bg_pad_bottom = get_effective_int(meta, draft, "es_bg_pad_bottom")
local bg_extra_w = get_effective_int(meta, draft, "es_bg_extra_w")
local bg_extra_h = get_effective_int(meta, draft, "es_bg_extra_h")
local text_base_x_adj = get_effective_int(meta, draft, "es_text_base_x_adj")
local icon_base_x_adj = get_effective_int(meta, draft, "es_icon_base_x_adj")
local top_margin_adj = get_effective_int(meta, draft, "es_top_margin_adj")
local bottom_margin_adj = get_effective_int(meta, draft, "es_bottom_margin_adj")
local title_y = 16 + title_offset_y
local subtitle_y = 34 + subtitle_offset_y
local first_line_y = 52 + get_effective_int(meta, draft, "es_first_line_y_adj")
local line_step = math.max(10, 18 + get_effective_int(meta, draft, "es_line_step_adj"))
local footer_nudge = -10 + get_effective_int(meta, draft, "es_footer_nudge_adj")
local footer_y = first_line_y + (line_count * line_step) + 10 + footer_nudge
local content_top = title_y - 8 - bg_pad_top
local content_bottom = footer_y + 22 + bg_pad_bottom
local base_footer_y = first_line_y + (BASE_LAYOUT_LINE_ROWS * line_step) + 10 + footer_nudge
local base_content_bottom = base_footer_y + 22 + bg_pad_bottom
local base_bg_height = (base_content_bottom - content_top) + bg_extra_h
local max_bg_height = math.max(52, math.floor(base_bg_height * MAX_LAYOUT_SCALE_FACTOR))
local bg_height = (content_bottom - content_top) + bg_extra_h
local bg_width = estimate_panel_width(view_model, layout_lines) + bg_pad_left + bg_pad_right + bg_extra_w
local top_screen_margin = 10 + top_margin_adj
local bottom_screen_margin = 22 + bottom_margin_adj
bg_height = clamp(bg_height, 52, max_bg_height)
bg_width = clamp(bg_width, 240, 1260)
local origin_y
if layout.y_mode == "top" then
origin_y = top_screen_margin
elseif layout.y_mode == "middle" then
origin_y = -math.floor(bg_height / 2)
else
origin_y = -(bg_height + bottom_screen_margin)
end
origin_y = origin_y + global_offset_y
local center_x = global_offset_x
if layout.x_mode == "right" then
center_x = -math.floor(bg_width / 2) - 16
elseif layout.x_mode == "left" then
center_x = math.floor(bg_width / 2) + 16
end
local left_edge_x = center_x - math.floor(bg_width / 2)
local icon_x = left_edge_x + 28 + bg_pad_left + icon_base_x_adj + icon_offset_x
local text_x = left_edge_x + (view_model.icon and 68 or 16) + bg_pad_left + text_base_x_adj
local bg_scale_x_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_x_pct"), 25, 300)
local bg_scale_y_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_y_pct"), 25, 300)
local bg_draw_w = math.max(1, math.floor(bg_width * (bg_scale_x_pct / 100)))
local bg_draw_h = math.max(1, math.floor(bg_height * (bg_scale_y_pct / 100)))
local title_scale = clamp(get_effective_int(meta, draft, "es_title_scale_pct"), 40, 300) / 100
local subtitle_scale = clamp(get_effective_int(meta, draft, "es_subtitle_scale_pct"), 40, 300) / 100
local line_scale = clamp(get_effective_int(meta, draft, "es_line_scale_pct"), 40, 300) / 100
local footer_scale = clamp(get_effective_int(meta, draft, "es_footer_scale_pct"), 40, 300) / 100
local bg_center_y = origin_y + math.floor(bg_height / 2) - bg_pad_top
-- Geometry channel: position, offset, scale.
-- Only sent when layout or panel dimensions actually changed.
local geom_key = (view_model.layout or "")
.. "|" .. (view_model.layout_signature or "")
.. "|" .. tostring(bg_draw_w)
.. "|" .. tostring(bg_draw_h)
.. "|" .. tostring(line_count)
.. "|" .. (view_model.icon and tostring(math.floor(view_model.icon.scale.x * 64)) or "0")
if geom_key ~= (state.last_geom_key or "") then
state.last_geom_key = geom_key
player:hud_change(state.hud_ids.bg, "position", layout.pos)
player:hud_change(state.hud_ids.bg, "offset", { x = center_x + bg_offset_x, y = bg_center_y + bg_offset_y })
player:hud_change(state.hud_ids.bg, "scale", { x = bg_draw_w, y = bg_draw_h })
player:hud_change(state.hud_ids.title, "position", layout.pos)
player:hud_change(state.hud_ids.title, "offset", { x = text_x + title_offset_x, y = origin_y + title_y })
player:hud_change(state.hud_ids.title, "scale", { x = title_scale, y = title_scale })
player:hud_change(state.hud_ids.subtitle, "position", layout.pos)
player:hud_change(state.hud_ids.subtitle, "offset", { x = text_x + subtitle_offset_x, y = origin_y + subtitle_y })
player:hud_change(state.hud_ids.subtitle, "scale", { x = subtitle_scale, y = subtitle_scale })
player:hud_change(state.hud_ids.footer, "position", layout.pos)
player:hud_change(state.hud_ids.footer, "offset",
{ x = text_x + footer_offset_x, y = origin_y + footer_y + footer_offset_y })
player:hud_change(state.hud_ids.footer, "scale", { x = footer_scale, y = footer_scale })
if view_model.icon then
player:hud_change(state.hud_ids.icon, "position", layout.pos)
player:hud_change(state.hud_ids.icon, "offset", { x = icon_x, y = origin_y + 34 + icon_offset_y })
player:hud_change(state.hud_ids.icon, "scale", view_model.icon.scale)
end
-- Geometry for all allocated line slots so new/reactivated slots are positioned correctly.
for index, line_id in ipairs(state.hud_ids.lines) do
player:hud_change(line_id, "position", layout.pos)
player:hud_change(line_id, "offset", {
x = text_x + lines_offset_x,
y = origin_y + first_line_y + ((index - 1) * line_step) + lines_offset_y,
})
player:hud_change(line_id, "scale", { x = line_scale, y = line_scale })
end
end
-- Content channel: text, number/color -- always applied when view_model changed.
player:hud_change(state.hud_ids.bg, "text", "eye_spy_hud_bg.png^[multiply:#" .. view_model.bg_color)
player:hud_change(state.hud_ids.title, "text", view_model.title)
player:hud_change(state.hud_ids.title, "number", view_model.title_color)
player:hud_change(state.hud_ids.subtitle, "text", view_model.subtitle)
player:hud_change(state.hud_ids.subtitle, "number", view_model.subtitle_color)
player:hud_change(state.hud_ids.footer, "text", view_model.footer)
player:hud_change(state.hud_ids.footer, "number", view_model.footer_color)
if view_model.icon then
player:hud_change(state.hud_ids.icon, "text", view_model.icon.text)
else
player:hud_change(state.hud_ids.icon, "text", "")
end
for index, line_id in ipairs(state.hud_ids.lines) do
local line = layout_lines[index]
if line then
player:hud_change(line_id, "text", line.text)
player:hud_change(line_id, "number", line.color)
else
player:hud_change(line_id, "text", "")
end
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+60
View File
@@ -0,0 +1,60 @@
[eye_spy]
[Entities]
# Shows entities you are looking at, displaying their name, health, and type.
eye_spy.show_entities (Show Entities) bool true
[Liquids]
# Enables liquid-aware targeting. When enabled, Eye Spy can scan through liquid
# surfaces, show the liquid line, and still show the target block behind it.
eye_spy.show_liquids (Show Liquids) bool false
[Display]
# Always shows "Hand" as the required tool in creative mode, regardless of node type.
eye_spy.always_show_hand_in_creative (Always Show "Hand" in Creative Mode) bool true
# Forces all item/node icons in the HUD to render at this pixel size.
# When set to None, each texture is displayed at its native size.
# Note: when mod security is disabled, sizes are detected automatically.
eye_spy.texture_pack_size (Texture Pack Size) enum None None,1x1px,2x2px,4x4px,8x8px,16x16px,32x32px,64x64px,128x128px,256x256px,512x512px,1024x1024px
# Shows growth progress and soil condition lines for supported plants and saplings.
eye_spy.show_growth (Show Growth Details) bool true
# Shows a small icon representing the current target in the HUD panel.
eye_spy.show_icons (Show Target Icon) bool true
[Performance]
# Sets the initial server-wide HUD update interval in seconds.
# This value is only applied on the first server start if no override has been
# saved via /eye_spy_rate. After that, use /eye_spy_rate to change the interval
# at runtime without restarting the server.
eye_spy.update_interval (Update Interval) float 0.1 0.05 5.0
# Enable lightweight Eye Spy performance metrics collection.
# Can also be toggled at runtime with /eye_spy_perf on|off.
eye_spy.enable_perf_metrics (Enable Performance Metrics) bool false
[Defaults]
# Default HUD visibility settings applied to new players on first join.
# Players can override all of these individually in /eye_spy_settings.
# Show coordinates of the targeted block/entity in the HUD footer.
eye_spy.default_show_coords (Default: Show Coords in Footer) bool false
# Show the ambient light level at the targeted block.
eye_spy.default_show_light_level (Default: Show Light Level) bool true
# Show a mob-spawn risk estimate based on light level at the targeted block.
eye_spy.default_show_spawn_hint (Default: Show Spawn Hint) bool false
# Show liquid name and level when looking at or through liquid.
eye_spy.default_show_liquid_info (Default: Show Liquid Info) bool true
# Show the estimated time to dig the targeted block with the current tool.
eye_spy.default_show_dig_time (Default: Show Dig Time) bool true
+532
View File
@@ -0,0 +1,532 @@
local S = eye_spy.S
eye_spy.target = {}
-- Returns the first line of a string, or nil if empty.
local function first_line(text)
if not text or text == "" then
return nil
end
return text:match("^[^\n]*")
end
-- Returns a compact cache key string for a position.
local function pos_key(pos)
return string.format("%d,%d,%d", pos.x, pos.y, pos.z)
end
-- Extracts the best available texture from a node definition.
local function extract_node_texture(def)
if not def or not def.tiles then
return "eye_spy_default_texture.png"
end
return def.tiles[6] or def.tiles[1] or "eye_spy_default_texture.png"
end
-- Normalises a single tile entry (string or table) to a texture string.
local function extract_tile_texture(tile)
if type(tile) == "table" then
return tile.name or tile.image or ""
end
return type(tile) == "string" and tile or ""
end
-- Extracts the best available texture from an item/node definition.
local function extract_item_texture(item_name, def)
local item_def = def or (item_name and minetest.registered_items[item_name]) or nil
if not item_def then
return "eye_spy_default_texture.png"
end
if item_def.inventory_image and item_def.inventory_image ~= "" then
return item_def.inventory_image
end
if item_def.wield_image and item_def.wield_image ~= "" then
return item_def.wield_image
end
if item_def.tiles then
local tile = extract_tile_texture(item_def.tiles[1])
if tile ~= "" then
return tile
end
end
return "eye_spy_default_texture.png"
end
-- Extracts the first texture from a luaentity definition.
local function extract_entity_texture(entity)
if not entity then
return "eye_spy_default_texture.png"
end
if entity.initial_properties and entity.initial_properties.textures then
return entity.initial_properties.textures[1] or "eye_spy_default_texture.png"
end
if entity.textures then
return entity.textures[1] or "eye_spy_default_texture.png"
end
return "eye_spy_default_texture.png"
end
-- Attempts to retrieve an ItemStack carried by a dropped-item entity.
local function get_entity_itemstack(entity)
if not entity then
return nil
end
local raw = entity.itemstring or entity._itemstring or entity.item or entity.stackstring
local stack
if type(raw) == "string" and raw ~= "" then
stack = ItemStack(raw)
elseif raw and type(raw) == "userdata" and raw.get_name then
stack = raw
end
if stack and not stack:is_empty() then
return stack
end
return nil
end
-- Returns a brief summary of item-stack metadata for display purposes.
-- Returns: summary_string, has_meta (bool), has_custom_description (bool)
local function summarize_item_meta(stack)
if not stack or stack:is_empty() then
return nil, false, false
end
local meta = stack:get_meta()
if not meta then
return nil, false, false
end
local custom_description = first_line(meta:get_string("description"))
local table_data = meta:to_table() or {}
local fields = table_data.fields or {}
local field_count = 0
for key, value in pairs(fields) do
if key ~= "description" and value ~= nil and tostring(value) ~= "" then
field_count = field_count + 1
end
end
local inv_count = 0
local inventory = table_data.inventory or {}
for _, list in pairs(inventory) do
if type(list) == "table" and #list > 0 then
inv_count = inv_count + 1
end
end
local parts = {}
if field_count > 0 then
parts[#parts + 1] = tostring(field_count) .. "f"
end
if inv_count > 0 then
parts[#parts + 1] = tostring(inv_count) .. "inv"
end
local has_meta = #parts > 0
return table.concat(parts, ", "), has_meta, custom_description ~= nil and custom_description ~= ""
end
-- Builds a target table for a dropped-item entity, or returns nil if not applicable.
local function get_dropped_item_target(ref, entity)
local stack = get_entity_itemstack(entity)
if not stack then
return nil
end
local item_name = stack:get_name()
if item_name == "" then
return nil
end
local item_def = minetest.registered_items[item_name] or {}
local entity_pos = ref and ref:get_pos() or { x = 0, y = 0, z = 0 }
local count = stack:get_count() or 1
local meta_summary, has_meta, has_custom_description = summarize_item_meta(stack)
local custom_description = has_custom_description and first_line(stack:get_meta():get_string("description")) or nil
local stack_description = nil
if stack.get_short_description then
stack_description = first_line(stack:get_short_description())
end
if not stack_description and stack.get_description then
stack_description = first_line(stack:get_description())
end
local base_description = custom_description
or stack_description
or first_line(item_def.short_description)
or first_line(item_def.description)
or eye_spy.readable_name_from_id(item_name)
or S("Unknown")
local description = count > 1 and (base_description .. " x" .. count) or base_description
return {
kind = "item",
key = "item:" .. item_name .. ":" .. count .. ":" .. tostring(ref),
name = item_name,
full_name = item_name,
description = description,
modname = eye_spy.get_modname_from_obj(item_name) or S("Unknown"),
pos = vector.new(entity_pos),
ref = ref,
stack = stack,
stack_count = count,
stack_wear = stack:get_wear() or 0,
stack_has_meta = has_meta,
stack_meta_summary = meta_summary,
stack_has_custom_description = has_custom_description,
texture = extract_item_texture(item_name, item_def),
mesh = nil,
light_level = nil,
item_def_description = item_def.short_description or item_def.description or "",
}
end
-- Returns positions to sample when determining a node's ambient light level.
local function get_light_sample_positions(pos, def)
local positions = {
{ x = pos.x, y = pos.y, z = pos.z },
{ x = pos.x, y = pos.y + 1, z = pos.z },
{ x = pos.x + 1, y = pos.y, z = pos.z },
{ x = pos.x - 1, y = pos.y, z = pos.z },
{ x = pos.x, y = pos.y, z = pos.z + 1 },
{ x = pos.x, y = pos.y, z = pos.z - 1 },
}
-- For non-walkable nodes also sample below (e.g. torches, plants).
if def and not def.walkable then
positions[#positions + 1] = { x = pos.x, y = pos.y - 1, z = pos.z }
end
return positions
end
-- Microsecond TTL for the light-sample cache.
local LIGHT_CACHE_TTL_US = 200000
local light_sample_cache = {}
local light_sample_cache_size = 0
-- Returns the highest light value among all sampled neighbour positions.
local function get_best_node_light(pos, def)
local best_light
for _, sample_pos in ipairs(get_light_sample_positions(pos, def)) do
local light = minetest.get_node_light(sample_pos)
if light ~= nil and (best_light == nil or light > best_light) then
best_light = light
end
end
return best_light
end
-- Cached wrapper around get_best_node_light; evicts the cache when it grows
-- beyond 1 024 entries.
local function get_cached_node_light(pos, def)
local now_us = minetest.get_us_time and minetest.get_us_time() or 0
if now_us <= 0 then
return get_best_node_light(pos, def)
end
local cache_key = pos_key(pos) .. ":" .. ((def and def.walkable) and "w" or "nw")
local cached = light_sample_cache[cache_key]
if cached and (now_us - cached.time_us) <= LIGHT_CACHE_TTL_US then
return cached.value
end
local sampled = get_best_node_light(pos, def)
if light_sample_cache_size > 1024 then
light_sample_cache = {}
light_sample_cache_size = 0
end
if not light_sample_cache[cache_key] then
light_sample_cache_size = light_sample_cache_size + 1
end
light_sample_cache[cache_key] = {
value = sampled,
time_us = now_us,
}
return sampled
end
-- Returns the current HP of an entity, preferring the luaentity field over the
-- object method.
local function get_entity_hp(ref, entity)
local hp = entity and (entity.health or entity.hp)
if hp == nil and ref then
hp = ref:get_hp()
end
return hp
end
-- Fix 3: Returns the maximum HP of an entity.
-- Checks luaentity fields first, then the Minetest 5.8+ get_max_hp() API,
-- and only falls back to current HP as a last resort.
local function get_entity_max_hp(ref, entity)
if entity then
local m = entity.max_health or entity.max_hp or entity.hp_max
if m then return m end
end
-- Use get_max_hp() if available (Minetest 5.8+)
if ref and ref.get_max_hp then
local max_hp = ref:get_max_hp()
if max_hp and max_hp > 0 then return max_hp end
end
if ref then
return ref:get_hp() -- last resort: current HP
end
end
-- Fix 4: Returns a synthetic target table used for UI preview rendering.
-- When preview_type == "Node" a node target is returned; otherwise an entity
-- target is returned (this was previously inverted).
function eye_spy.target.get_preview(preview_type)
if preview_type == "Node" then
local def = minetest.registered_nodes["eye_spy:preview_node"]
return {
kind = "node",
key = "preview:node",
name = "eye_spy:preview_node",
full_name = "eye_spy:preview_node",
description = def and def.description or S("Preview") .. " " .. S("Node"),
modname = "eye_spy",
pos = { x = 0, y = 0, z = 0 },
def = def,
texture = def and def.tiles and def.tiles[1] or "eye_spy_default_texture.png",
mesh = def and def.mesh or "eye_spy_default_mesh.obj",
color = def and def.color,
palette = def and def.palette,
light_level = 15,
is_liquid = false,
}
end
-- Default (including "Entity"): return an entity preview target.
local entity_def = minetest.registered_entities["eye_spy:preview_entity"]
local props = entity_def and entity_def.initial_properties or {}
return {
kind = "entity",
key = "preview:entity",
name = "eye_spy:preview_entity",
full_name = "eye_spy:preview_entity",
description = props.description or S("Preview") .. " " .. S("Entity"),
modname = "eye_spy",
pos = { x = 0, y = 0, z = 0 },
texture = props.textures and props.textures[1] or "eye_spy_default_texture.png",
mesh = props.mesh or "eye_spy_default_mesh.obj",
hp = props.health or props.hp or 8,
max_hp = props.max_health or props.max_hp or props.hp_max or 10,
light_level = 15,
}
end
-- Casts a ray from the player's eye and returns a target table describing
-- whatever the player is looking at (node, entity, dropped item, or air).
function eye_spy.target.acquire(player, opts)
local options = opts or {}
if options.preview then
return eye_spy.target.get_preview(options.preview_type)
end
local pos = player:get_pos()
local meta = player and player:get_meta() or nil
-- Fix 1: (meta and X) or true is always true in Lua.
-- Correct form: absent meta means show everything; present meta is checked.
local show_light_level = (not meta) or (meta:get_string("es_show_light_level") ~= "false")
local show_spawn_hint = (not meta) or (meta:get_string("es_show_spawn_hint") ~= "false")
local show_liquid_info = (not meta) or (meta:get_string("es_show_liquid_info") ~= "false")
local need_node_light = show_light_level or show_spawn_hint
local properties = player:get_properties()
local eye_height = properties.eye_height or 1.625
-- Fix 5: delegate range calculation to the shared helper in tools.lua.
local range = eye_spy.get_player_range(player)
pos.y = pos.y + eye_height
local dir = player:get_look_dir()
local start = vector.add(pos, vector.multiply(dir, 0.2))
local finish = vector.add(start, vector.multiply(dir, range))
local allow_liquids = eye_spy.config.show_liquids == true and show_liquid_info
local ray = minetest.raycast(start, finish, eye_spy.config.show_entities, allow_liquids)
local first_liquid = nil
local primary_target = nil
-- Builds a complete node target table.
local function make_node_target(node, under_pos, def, is_liquid, liquid_type, liquid_level)
return {
kind = "node",
key = "node:" .. node.name .. "@" .. pos_key(under_pos),
name = node.name,
full_name = node.name,
description = first_line(def and def.description)
or eye_spy.readable_name_from_id(node.name)
or S("Unknown"),
modname = eye_spy.get_modname_from_obj(node.name) or S("Unknown"),
pos = vector.new(under_pos),
def = def,
texture = extract_node_texture(def),
mesh = def and def.mesh or "eye_spy_default_mesh.obj",
color = def and def.color,
palette = def and def.palette,
light_level = need_node_light and get_cached_node_light(under_pos, def) or nil,
is_liquid = is_liquid,
liquid_type = liquid_type,
liquid_level = liquid_level,
behind_liquid = false,
liquid_obstacle = nil,
}
end
for hit in ray do
if hit.type == "node" then
local node = minetest.get_node(hit.under)
if node.name ~= "air" then
local def = minetest.registered_nodes[node.name]
local liquid_type = def and def.liquidtype or "none"
local is_liquid = liquid_type ~= "none"
local liquid_level
if is_liquid then
if liquid_type == "source" then
liquid_level = 8
else
liquid_level = ((node.param2 or 0) % 8) + 1
end
if not first_liquid then
first_liquid = {
name = node.name,
full_name = node.name,
pos = vector.new(hit.under),
liquid_type = liquid_type,
liquid_level = liquid_level,
}
end
else
primary_target = make_node_target(node, hit.under, def, false, liquid_type, liquid_level)
break
end
end
elseif hit.type == "object" and hit.ref and not hit.ref:is_player() then
local entity = hit.ref:get_luaentity()
local dropped_item = get_dropped_item_target(hit.ref, entity)
if dropped_item then
dropped_item.behind_liquid = false
dropped_item.liquid_obstacle = nil
primary_target = dropped_item
break
end
local entity_name = entity and entity.name or "unknown"
local entity_pos = hit.ref:get_pos() or { x = 0, y = 0, z = 0 }
primary_target = {
kind = "entity",
key = "entity:" .. entity_name .. ":" .. tostring(hit.ref),
name = entity_name,
full_name = entity_name,
description = first_line(entity and entity.description)
or eye_spy.readable_name_from_id(entity_name)
or S("Unknown"),
modname = eye_spy.get_modname_from_obj(entity_name) or S("Unknown"),
pos = vector.new(entity_pos),
ref = hit.ref,
texture = extract_entity_texture(entity),
mesh = entity and entity.mesh or "eye_spy_default_mesh.obj",
hp = get_entity_hp(hit.ref, entity),
max_hp = get_entity_max_hp(hit.ref, entity),
light_level = nil,
behind_liquid = false,
liquid_obstacle = nil,
}
break
end
end
if primary_target then
if first_liquid then
primary_target.behind_liquid = true
primary_target.liquid_obstacle = first_liquid
end
return primary_target
end
-- No solid target found — return the liquid itself if one was encountered.
if first_liquid then
local node_name = first_liquid.name
local node_pos = first_liquid.pos
local def = minetest.registered_nodes[node_name]
return {
kind = "node",
key = "node:" .. node_name .. "@" .. pos_key(node_pos),
name = node_name,
full_name = node_name,
description = first_line(def and def.description)
or eye_spy.readable_name_from_id(node_name)
or S("Unknown"),
modname = eye_spy.get_modname_from_obj(node_name) or S("Unknown"),
pos = vector.new(node_pos),
def = def,
texture = extract_node_texture(def),
mesh = def and def.mesh or "eye_spy_default_mesh.obj",
color = def and def.color,
palette = def and def.palette,
light_level = need_node_light and get_cached_node_light(node_pos, def) or nil,
is_liquid = true,
liquid_type = first_liquid.liquid_type,
liquid_level = first_liquid.liquid_level,
behind_liquid = false,
liquid_obstacle = nil,
}
end
return { kind = "air", key = "air", behind_liquid = false, liquid_obstacle = nil }
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

+282
View File
@@ -0,0 +1,282 @@
-- eye_spy/tools.lua
-- Tool detection and dig-hint helpers.
-- Extracted from functions.lua. Runs after functions.lua, so eye_spy.S and
-- eye_spy.cg are already available when this file is executed.
local S = eye_spy.S
------------------------------------------------------------
-- Group → tool-type mappings for Minetest Game
------------------------------------------------------------
-- Maps dig group names to human-readable tool-type labels (MTG).
local GROUP_TO_TOOL_MTG = {
cracky = S("Pickaxe"),
crumbly = S("Shovel"),
choppy = S("Axe"),
snappy = S("Sword"),
dig_immediate = S("Hand"),
}
-- Priority order for MTG group lookup (most-specific first).
local GROUP_ORDER_MTG = { "dig_immediate", "snappy", "crumbly", "choppy", "cracky" }
------------------------------------------------------------
-- Group → tool-type mappings for MineClone / VoxeLibre
------------------------------------------------------------
-- Maps dig group names to human-readable tool-type labels (MCL).
local GROUP_TO_TOOL_MC = {
swordy = S("Sword"),
handy = S("Hand"),
pickaxey = S("Pickaxe"),
shovely = S("Shovel"),
axey = S("Axe"),
shearsy = S("Shears"),
dig_immediate = S("Hand"),
}
-- Priority order for MCL group lookup (most-specific first).
local GROUP_ORDER_MC = { "dig_immediate", "handy", "swordy", "shearsy", "shovely", "axey", "pickaxey" }
------------------------------------------------------------
-- Internal helpers
------------------------------------------------------------
-- Returns a shallow copy of an ipairs-iterable list.
local function clone_list(values)
local result = {}
for index, value in ipairs(values or {}) do
result[index] = value
end
return result
end
-- Appends `value` to `list` only when it is not already present.
-- Ignores nil and empty-string values.
local function append_unique(list, value)
if not value or value == "" then
return
end
for _, existing in ipairs(list) do
if existing == value then
return
end
end
list[#list + 1] = value
end
-- Returns the group→tool map and ordered group list appropriate for the
-- currently-running game. Uses eye_spy.cg (set by init.lua).
local function get_tool_mapping(game_id)
if game_id == "mineclone" then
return GROUP_TO_TOOL_MC, GROUP_ORDER_MC
end
return GROUP_TO_TOOL_MTG, GROUP_ORDER_MTG
end
-- Collects every tool-type label that applies to `node_groups`, in priority
-- order, de-duplicated.
local function collect_tool_types(node_groups, group_to_tool, group_order)
local result = {}
for _, group in ipairs(group_order) do
if node_groups[group] and group_to_tool[group] then
append_unique(result, group_to_tool[group])
end
end
return result
end
-- Returns the single highest-priority dig group and its tool-type label for
-- `node_groups`. Returns nil, S("None") when no matching group is found.
local function get_preferred_tool_group(node_groups, group_to_tool, group_order)
for _, group in ipairs(group_order) do
if node_groups[group] and group_to_tool[group] then
return group, group_to_tool[group]
end
end
return nil, S("None")
end
-- Builds a display string from the full set of required tool types and the
-- single best/preferred tool type.
--
-- Examples:
-- { "Pickaxe" }, "Pickaxe" → "Pickaxe"
-- { "Axe", "Pickaxe" }, "Pickaxe" → "Axe / Pickaxe | Pickaxe"
local function format_tool_summary(minimum_types, best_type)
local minimum_label = table.concat(minimum_types or {}, " / ")
if best_type and best_type ~= "" and best_type ~= S("None") and best_type ~= S("Unknown") then
if minimum_label == "" then
return best_type
end
-- Single entry that matches best_type exactly — no need to repeat it.
if #minimum_types == 1 and minimum_types[1] == best_type then
return best_type
end
-- If best_type is already listed in minimum_types, just show the list.
for _, minimum_type in ipairs(minimum_types or {}) do
if minimum_type == best_type then
return minimum_label
end
end
-- best_type is not in the minimum list — append it after a separator.
return minimum_label .. " | " .. best_type
end
if minimum_label ~= "" then
return minimum_label
end
return S("Unknown")
end
-- Returns true when `itemdef` has groupcaps for `preferred_group`.
-- dig_immediate is never considered a "match" because any item can break those
-- nodes instantly.
local function tool_matches_group(itemdef, preferred_group)
if not preferred_group or preferred_group == "dig_immediate" then
return false
end
local caps = itemdef and itemdef.tool_capabilities
local groupcaps = caps and caps.groupcaps
return groupcaps and groupcaps[preferred_group] ~= nil or false
end
------------------------------------------------------------
-- Public API
------------------------------------------------------------
-- Returns whether `toolstack` can dig `nodename`, plus the dig time.
-- Returns false, nil when `toolstack` is nil or the node is unknown.
function eye_spy.tool_can_dig_node(toolstack, nodename)
-- Nil-guard: no stack means no digging ability.
if not toolstack then
return false, nil
end
local ndef = minetest.registered_nodes[nodename]
if not ndef then
return false, nil
end
local tool_caps = toolstack:get_tool_capabilities()
local node_groups = ndef.groups or {}
local dig_params = minetest.get_dig_params(node_groups, tool_caps)
return dig_params.diggable, dig_params.time
end
-- Returns a filtered copy of `source` that omits every entry whose `.name`
-- field contains any substring listed in `blocked`.
function eye_spy.filter_out_by_name(source, blocked)
local result = {}
for _, entry in ipairs(source) do
local name = entry.name or ""
local keep = true
for _, word in ipairs(blocked) do
if string.find(name, word, 1, true) then
keep = false
break
end
end
if keep then
table.insert(result, entry)
end
end
return result
end
-- Returns the highest `maxlevel` value found across all groupcaps of
-- `itemdef`. Returns 0 when `itemdef` has no tool_capabilities.
function eye_spy.get_tool_maxlevel(itemdef)
local maxlevel = 0
if not itemdef.tool_capabilities then
return 0
end
for _, gc in pairs(itemdef.tool_capabilities.groupcaps or {}) do
if gc.maxlevel and gc.maxlevel > maxlevel then
maxlevel = gc.maxlevel
end
end
return maxlevel
end
-- Returns a human-readable tool-hint string for `node_name` (or nil when no
-- hint is applicable) and a boolean indicating whether the currently wielded
-- `toolstack` is an appropriate tool for digging that node.
function eye_spy.get_node_tool_hint_and_match(node_name, toolstack)
if not node_name or node_name == "" then
return nil, false
end
local def = minetest.registered_nodes[node_name]
if not def or not def.groups then
return nil, false
end
local node_groups = def.groups
local group_to_tool, group_order = get_tool_mapping(eye_spy.cg)
local minimum_types = collect_tool_types(node_groups, group_to_tool, group_order)
local preferred_group, tooltype = get_preferred_tool_group(node_groups, group_to_tool, group_order)
local summary = format_tool_summary(minimum_types, tooltype)
-- Suppress generic/unhelpful labels.
if summary == S("Unknown") or summary == S("None") then
summary = nil
end
-- Nodes with no preferred dig group (or dig_immediate) are considered
-- compatible with any held item for colour-state purposes.
if not preferred_group or preferred_group == "dig_immediate" then
return summary, true
end
local itemdef = toolstack and toolstack.get_definition and toolstack:get_definition() or nil
local caps = itemdef and itemdef.tool_capabilities or nil
local groupcaps = caps and caps.groupcaps or nil
local matches = groupcaps and groupcaps[preferred_group] ~= nil or false
return summary, matches
end
-- Returns the effective reach range for `player` based on the wielded item
-- definition, falling back to creative-mode range (10) or default range (4).
function eye_spy.get_player_range(player)
local wield = player:get_wielded_item()
local def = wield:get_definition()
-- Nil-guard: definition may be absent for unknown item types.
if def and def.range then
return def.range
end
if minetest.is_creative_enabled(player:get_player_name()) then
return 10
end
return 4
end
+1173
View File
File diff suppressed because it is too large Load Diff