Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424aa67b6e | ||
|
|
549a4ad81b | ||
|
|
7726b9aa4e | ||
|
|
a90aad5b67 | ||
|
|
806e023657 | ||
|
|
39fb971202 | ||
|
|
adc4245c71 | ||
|
|
a4a0b07dfb | ||
|
|
713957c382 | ||
|
|
16a1c283f2 | ||
|
|
f0cb770372 | ||
|
|
44b85b2c17 | ||
|
|
4b962cf55e | ||
|
|
867b7b3484 | ||
|
|
54c2bdc301 | ||
|
|
b0f6330d6e | ||
|
|
ffbacd2962 | ||
|
|
7cd2664ed4 | ||
|
|
9ea2cdb378 | ||
|
|
d99e037f9a | ||
|
|
2a8cd059b8 | ||
|
|
85c075ad4d | ||
|
|
3b5a27e71b | ||
|
|
5c18ee6e20 | ||
|
|
d87a97bb08 | ||
|
|
4a303c1768 | ||
|
|
0926bcc942 | ||
|
|
0d8dbaf9d7 | ||
|
|
d25fcb3a1f | ||
|
|
0edd1f28e5 | ||
|
|
065df5755f | ||
|
|
d73fc25c96 | ||
|
|
19db638891 | ||
|
|
bfab25826f | ||
|
|
435ee95094 | ||
|
|
6a48a17873 | ||
|
|
14ac8ee526 | ||
|
|
4ee9566f10 | ||
|
|
1d67bac8c8 | ||
|
|
f565ce54b9 | ||
|
|
e328f959a6 | ||
|
|
b91103ba9a | ||
|
|
807622c418 | ||
|
|
8a97f07c5a | ||
|
|
d1bfffe49b | ||
|
|
f33cf9fb0d | ||
|
|
66decb36ae | ||
|
|
16f04aab1a | ||
|
|
a5d698a266 | ||
|
|
a16cda7c22 | ||
|
|
599a7de7fc | ||
|
|
0eab4f7f4d | ||
|
|
179cb69146 | ||
|
|
6d4472cb0e | ||
|
|
2ec966b567 | ||
|
|
c2c697dc4a | ||
|
|
f615f689f6 | ||
|
|
bb1d951c73 | ||
|
|
a9308a5aac | ||
|
|
f7135fc9a6 | ||
|
|
6f27717d88 | ||
|
|
178c464cae | ||
|
|
0c6cce2a4f | ||
|
|
4194f674ad | ||
|
|
e640d0ee1d | ||
|
|
9001db4a5c | ||
|
|
1ae9b5553b | ||
|
|
8b0787f87e | ||
|
|
8eed5bd45c | ||
|
|
af6b0994cb | ||
|
|
dbb36ec696 | ||
|
|
78144fd7e2 | ||
|
|
65c6a2a0a2 | ||
|
|
68a005267a | ||
|
|
15284f166f | ||
|
|
12f86d4240 | ||
|
|
ea53f8e5b3 | ||
|
|
c7d61d13ba | ||
|
|
e77cfcb378 | ||
|
|
99f18fd6f7 | ||
|
|
3977d02e2d | ||
|
|
fe20445e53 | ||
|
|
4670b60b9a | ||
|
|
e772713328 | ||
|
|
ae5dab6475 | ||
|
|
a5c53d7ec5 | ||
|
|
e66c3fa92c | ||
|
|
ffa95e5d54 | ||
|
|
683ebe4ac4 | ||
|
|
1ce3e6099e | ||
|
|
1fd4319454 | ||
|
|
8061c4b267 | ||
|
|
bb59fbc898 | ||
|
|
420f963fa9 | ||
|
|
3df48ca88d | ||
|
|
f09624cd9b | ||
|
|
f27cd1d2c2 | ||
|
|
c446e42b87 | ||
|
|
0486da53c0 | ||
|
|
705926f4b6 | ||
|
|
2ae0dc3727 | ||
|
|
7b7360ee03 | ||
|
|
d8b784ab9a | ||
|
|
7ee41b3bac | ||
|
|
7db0af38b3 | ||
|
|
0abaec8db0 | ||
|
|
e973a065e8 | ||
|
|
28b7f69a6c | ||
|
|
0d35c486e8 | ||
|
|
1080b22427 | ||
|
|
d82e4a619e | ||
|
|
b807744106 | ||
|
|
13f45d927c | ||
|
|
752c49eee2 | ||
|
|
9fa78a9b2d | ||
|
|
1f52f9f390 | ||
|
|
6da34147a4 | ||
|
|
0f8f884760 | ||
|
|
d1ebec19f0 | ||
|
|
e33eb0e1ee | ||
|
|
d5e0f669eb | ||
|
|
37e0c5879e | ||
|
|
182db3896d | ||
|
|
55463c78cd | ||
|
|
ded538698e | ||
|
|
6ccd650038 | ||
|
|
6f622a872f | ||
|
|
e258a14e4c | ||
|
|
23dac06c5e | ||
|
|
eaeea070c1 | ||
|
|
b929939a76 | ||
|
|
267946ae59 | ||
|
|
90e0f9463b | ||
|
|
246d8cf374 | ||
|
|
07b9fc1cc9 | ||
|
|
56b0559c92 | ||
|
|
2fd3e80501 | ||
|
|
69e82fbe83 | ||
|
|
1f381495aa | ||
|
|
e25290281e | ||
|
|
c000079ca9 | ||
|
|
7f12163765 | ||
|
|
510e2f3ea7 | ||
|
|
015b8c4302 | ||
|
|
37d1d8300c | ||
|
|
c2d8ea45ac | ||
|
|
1cf0e546d1 | ||
|
|
160b74ab5d | ||
|
|
c325dab2e2 | ||
|
|
e7dfc2e570 | ||
|
|
038f3c8d88 | ||
|
|
2364d92184 | ||
|
|
3d68399f37 | ||
|
|
9c96c78f3a | ||
|
|
2b2086da67 | ||
|
|
2565161cc8 | ||
|
|
b5b4708783 | ||
|
|
2012690609 | ||
|
|
ad9f112e75 | ||
|
|
904879555f | ||
|
|
0c03386337 | ||
|
|
1fcb3885b9 | ||
|
|
99b9fe49af | ||
|
|
3e47310f09 | ||
|
|
dcbea17ca8 | ||
|
|
5710f5abde | ||
|
|
bf96e9b44f | ||
|
|
26b4527fda | ||
|
|
617436942c | ||
|
|
a58ec4dfef | ||
|
|
7032771181 | ||
|
|
d85b6e0fec | ||
|
|
e8ac05c954 | ||
|
|
3f550e9dcd | ||
|
|
65a16aa4d1 | ||
|
|
6019f6c701 | ||
|
|
d8977b03f4 | ||
|
|
21d6d1727a | ||
|
|
55547c9448 | ||
|
|
981ee0c8a9 | ||
|
|
e506504b23 | ||
|
|
14932fe325 | ||
|
|
26dbac9350 | ||
|
|
56085a8204 | ||
|
|
ca93461378 | ||
|
|
091958b008 | ||
|
|
7ae344773e | ||
|
|
461c75a605 | ||
|
|
3c3c09ac13 | ||
|
|
84c05ac712 | ||
|
|
6bc22e49c6 | ||
|
|
3f843fc3b8 | ||
|
|
3c9026cf83 | ||
|
|
297ded94c6 | ||
|
|
e66210a493 | ||
|
|
38d38a4ecc | ||
|
|
f77d2501b5 | ||
|
|
c88b12095b | ||
|
|
3fd0545972 | ||
|
|
65aac6bb35 | ||
|
|
80eac8c53c | ||
|
|
973479e640 | ||
|
|
201deda397 | ||
|
|
448c002403 | ||
|
|
3fc27756df | ||
|
|
128d0a024d | ||
|
|
dfbe53518d | ||
|
|
08c5e9b5f8 | ||
|
|
a605536b46 | ||
|
|
5575b53b98 | ||
|
|
e3b5c10216 | ||
|
|
f3314f6a99 | ||
|
|
65646b6f98 | ||
|
|
93237bddc9 | ||
|
|
dee9e38b00 | ||
|
|
9ec54524cb | ||
|
|
78ea2683aa | ||
|
|
13dc0de0f7 | ||
|
|
39b7e0848f | ||
|
|
d88474d9c7 | ||
|
|
097630878f | ||
|
|
dcaf704bda | ||
|
|
7eea618d38 | ||
|
|
56ad4cf560 | ||
|
|
1cf5bc0de4 | ||
|
|
d6a8df928c | ||
|
|
4ec933de0b | ||
|
|
06e8d9c98d | ||
|
|
670c9858f2 | ||
|
|
18e9791f83 | ||
|
|
a4dcdd34c7 | ||
|
|
aa20c0a294 | ||
|
|
1184e5ffab | ||
|
|
c5a6ce353c | ||
|
|
70d013706c | ||
|
|
61b419fb46 | ||
|
|
a983bdf349 | ||
|
|
3b7a70b03b | ||
|
|
d5bf8992ad | ||
|
|
f1546bcf31 | ||
|
|
120736a7d0 | ||
|
|
d66b799109 | ||
|
|
2bed109a9a | ||
|
|
536434bdba | ||
|
|
e66b2426dd | ||
|
|
0b911738fe | ||
|
|
40d1fea5b6 | ||
|
|
9bd3447b6d | ||
|
|
53feb69c2d | ||
|
|
544fe14799 | ||
|
|
6c8b80d6b3 | ||
|
|
5544250e84 | ||
|
|
d3a5d66115 | ||
|
|
010bba5d48 | ||
|
|
7b0e5dc685 | ||
|
|
17f9a4c8df | ||
|
|
07a97c399e |
No files matched your search
@@ -10,13 +10,14 @@ jobs:
|
||||
steps:
|
||||
# Download code from repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup Java
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v1
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
# Build plugin
|
||||
- name: Build with Maven
|
||||
|
||||
+73
-19
@@ -5,55 +5,90 @@ on: [push]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 8
|
||||
continue-on-error: ${{ matrix.version == '1.20.4' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: ['bukkit', 'spigot', 'paper']
|
||||
version: ['1.16.5', '1.16.4', '1.16.3', '1.16.2', '1.16.1']
|
||||
flavor: ['bukkit', 'spigot', 'paper', 'purpur']
|
||||
version: ['1.20.4', '1.19.4', '1.18.2', '1.17.1', '1.16.5']
|
||||
steps:
|
||||
# Download code from repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup Java
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v1
|
||||
# Setup Java (for building)
|
||||
- name: Setup JDK (for building)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
# Build plugin
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --update-snapshots verify
|
||||
|
||||
# Setup Java (for testing)
|
||||
- name: Setup JDK (for testing)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: |
|
||||
${{
|
||||
startsWith(matrix.version, '1.16') && '11' ||
|
||||
startsWith(matrix.version, '1.17') && '16' ||
|
||||
startsWith(matrix.version, '1.18') && '17' ||
|
||||
startsWith(matrix.version, '1.19') && '17' ||
|
||||
'21'
|
||||
}}
|
||||
|
||||
# Setup Node.js
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# Setup automata
|
||||
- name: Setup automata
|
||||
working-directory: ./automata
|
||||
run: npm install
|
||||
|
||||
# Setup server
|
||||
- name: Setup server
|
||||
env:
|
||||
FLAVOR: ${{ matrix.flavor }}
|
||||
VERSION: ${{ matrix.version }}
|
||||
GETBUKKIT_SUBDOMAIN: ${{ startsWith(matrix.version, '1.16') && 'cdn' || 'download' }}
|
||||
run: |
|
||||
if [ $FLAVOR == "bukkit" ]; then
|
||||
url="https://cdn.getbukkit.org/craftbukkit/craftbukkit-$VERSION.jar"
|
||||
url="https://$GETBUKKIT_SUBDOMAIN.getbukkit.org/craftbukkit/craftbukkit-$VERSION.jar"
|
||||
elif [ $FLAVOR == "spigot" ]; then
|
||||
url="https://cdn.getbukkit.org/spigot/spigot-$VERSION.jar"
|
||||
url="https://$GETBUKKIT_SUBDOMAIN.getbukkit.org/spigot/spigot-$VERSION.jar"
|
||||
elif [ $FLAVOR == "paper" ]; then
|
||||
url="https://papermc.io/api/v1/paper/$VERSION/latest/download"
|
||||
build=$(curl "https://papermc.io/api/v2/projects/paper/versions/$VERSION" | jq '.builds[-1]')
|
||||
url="https://papermc.io/api/v2/projects/paper/versions/$VERSION/builds/$build/downloads/paper-$VERSION-$build.jar"
|
||||
elif [ $FLAVOR == "purpur" ]; then
|
||||
url="https://api.purpurmc.org/v2/purpur/$VERSION/latest/download"
|
||||
fi
|
||||
mkdir ./server && cd ./server
|
||||
wget -nv "$url" -O server.jar
|
||||
java -jar server.jar || true
|
||||
sed -i 's/eula=false/eula=true/g' eula.txt
|
||||
echo "allow-nether=false" >> server.properties
|
||||
echo "allow-end=false" >> server.properties
|
||||
echo 'online-mode=false' >> server.properties
|
||||
echo 'enable-rcon=true' >> server.properties
|
||||
echo 'rcon.password=rcon' >> server.properties
|
||||
echo 'allow-nether=false' >> server.properties
|
||||
echo 'allow-end=false' >> server.properties
|
||||
echo 'generate-structures=false' >> server.properties
|
||||
echo "level-type=flat" >> server.properties
|
||||
echo 'level-type=flat' >> server.properties
|
||||
|
||||
# Install dependencies
|
||||
- name: Install plugin dependencies
|
||||
run: |
|
||||
url="https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/target/ProtocolLib.jar"
|
||||
mkdir -p ./server/plugins
|
||||
url="https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/build/libs/ProtocolLib.jar"
|
||||
mkdir -p ./server/plugins/ProtocolLib
|
||||
wget -q "$url" -O ./server/plugins/ProtocolLib.jar
|
||||
echo -e "global:\n auto updater:\n notify: false\n download: false" > ./server/plugins/ProtocolLib/config.yml
|
||||
|
||||
# Install plugin
|
||||
- name: Install plugin
|
||||
@@ -68,9 +103,12 @@ jobs:
|
||||
# Test plugin
|
||||
- name: Run plugin in server
|
||||
working-directory: ./server
|
||||
env:
|
||||
FLAVOR: ${{ matrix.flavor }}
|
||||
run: |
|
||||
timeout -s SIGINT 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
|
||||
if ! grep -Fq '[YamipaPlugin] Loading YamipaPlugin' server.log; then
|
||||
(cd ../automata && npm run start) &
|
||||
timeout -s SIGINT -k 110s 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
|
||||
if ! grep -Fq '[YamipaPlugin] ' server.log; then
|
||||
echo "Plugin did not load"
|
||||
exit 1
|
||||
fi
|
||||
@@ -78,7 +116,23 @@ jobs:
|
||||
echo "Plugin did not enabled verbose mode"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] Found 5 file(s) in images directory' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] [ImageStorage] Found 5 file(s) in images directory' server.log; then
|
||||
echo "Plugin did not read image directory"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] [ImageCommandBridge] Fixed command permissions' server.log; then
|
||||
echo "Plugin did not fixed command permissions"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] [FakeImage] Created FakeImage' server.log; then
|
||||
echo "Plugin did not place the fake image"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] [FakeImage] Invalidated FakeImage' server.log; then
|
||||
echo "Plugin did not remove the fake image"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$FLAVOR" != "bukkit" ] && grep -iFq 'exception' server.log; then
|
||||
echo "Server threw an exception"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,3 +1,4 @@
|
||||
/.idea
|
||||
/target
|
||||
/*.iml
|
||||
node_modules
|
||||
@@ -2,16 +2,19 @@
|
||||
[](https://github.com/josemmo/yamipa/actions)
|
||||
[](https://github.com/josemmo/yamipa/releases/latest)
|
||||

|
||||
[](https://bstats.org/plugin/bukkit/Yamipa/10243)
|
||||
[](https://bstats.org/plugin/bukkit/Yamipa/10243)
|
||||
[](LICENSE)
|
||||
|
||||
Yamipa is an Spigot plugin that allows you to place images on any surface in your Minecraft server.
|
||||
Yamipa is a Spigot plugin that allows players to place images (even **animated**!) on any surface in your Minecraft server
|
||||
without having to install any local client mod.
|
||||
|
||||
It is designed with performance and compatibility in mind, so even the most low-specs servers should be able to run it.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://i.imgur.com/9rzeKFS.mp4"><img alt="Placing and removing image" src="screenshots/demo.gif" width="250"></a>
|
||||
<a href="screenshots/sample-1.jpg"><img alt="Sample Screenshot" src="screenshots/sample-1.jpg" width="250"></a>
|
||||
<a href="screenshots/sample-2.jpg"><img alt="Sample Screenshot" src="screenshots/sample-2.jpg" width="250"></a>
|
||||
<a href="https://imgur.com/9rzeKFS"><img alt="Placing and removing image" src="screenshots/demo.gif" width="300"></a>
|
||||
<a href="https://imgur.com/a/J22z9Dq"><img alt="Works with animated images too!" src="screenshots/demo-animated.gif" width="300"></a>
|
||||
<a href="https://imgur.com/a/laPF1eN"><img alt="Placing and removing image item" src="screenshots/demo-item.gif" width="300"></a>
|
||||
<a href="screenshots/sample.jpg"><img alt="Optimized to work in low-spec servers" src="screenshots/sample.jpg" width="300"></a>
|
||||
</p>
|
||||
|
||||
## Installation
|
||||
@@ -21,8 +24,17 @@ Download the JAR file for the [latest release](https://github.com/josemmo/yamipa
|
||||
### Requirements
|
||||
Before installing Yamipa make sure you meet the following requirements:
|
||||
|
||||
- CraftBukkit, Spigot or PaperMC v1.16.x
|
||||
- [ProtocolLib v4.x.x](https://www.spigotmc.org/resources/protocollib.1997/)
|
||||
- CraftBukkit, Spigot or PaperMC 1.16 or higher
|
||||
- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) v5.0.0 or higher
|
||||
|
||||
Here are the Minecraft distributions where Yamipa should be able to run:
|
||||
| Minecraft version | CraftBukkit | Spigot | PaperMC |
|
||||
|------------------:|:-----------:|:------:|:-------:|
|
||||
| 1.16.x | ✅ | ✅ | ✅ |
|
||||
| 1.17 & 1.17.1 | ✅ | ✅ | ✅ |
|
||||
| 1.18 & 1.18.x | ✅ | ✅ | ✅ |
|
||||
| 1.19 & 1.19.x | ✅ | ✅ | ✅ |
|
||||
| 1.20 & 1.20.x | ✅ | ✅ | ✅ |
|
||||
|
||||
## Configuration
|
||||
Yamipa is ready-to-go right out of the box. By default, it creates the following files and directories under the
|
||||
@@ -33,14 +45,50 @@ Yamipa is ready-to-go right out of the box. By default, it creates the following
|
||||
- `images.dat`: A file holding the list and properties (e.g. coordinates) of all placed images in your server. You
|
||||
shouldn't modify its contents.
|
||||
|
||||
You can change the default path of these files by creating a `config.yml` file in the plugin configuration directory:
|
||||
You can change the path of these files by creating a `config.yml` file in the plugin configuration directory.
|
||||
Here are the default configuration values if you don't specify them:
|
||||
```yaml
|
||||
verbose: false # Set to "true" to enable more verbose logging
|
||||
images-path: images # Path to images directory
|
||||
cache-path: cache # Path to cache directory
|
||||
data-path: images.dat # Path to placed images database file
|
||||
verbose: false # Set to "true" to enable more verbose logging
|
||||
animate-images: true # Set to "false" to disable GIF support
|
||||
images-path: images # Path to images directory
|
||||
cache-path: cache # Path to cache directory
|
||||
data-path: images.dat # Path to placed images database file
|
||||
allowed-paths: null # Set to a RegExp to limit accessible images to players
|
||||
max-image-dimension: 30 # Maximum width or height in blocks allowed in images
|
||||
```
|
||||
|
||||
For more information on how to set a different `allowed-paths` or `max-image-dimension` value per player, see the
|
||||
[Player variables](#player-variables) section.
|
||||
|
||||
### Allowed paths
|
||||
The variable `allowed-paths` is a regular expression that determines whether a player is allowed to see or download
|
||||
an image file. If the desired path relative to the images directory matches this expression, then the player is allowed
|
||||
to continue.
|
||||
|
||||
If `allowed-paths` is an empty string ("") or null, then the player can read any image file or download to any path
|
||||
inside the images directory.
|
||||
|
||||
This regular expression must follow
|
||||
[the syntax used by Java](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html). You can test your
|
||||
expression beforehand using an online tool like [regex101](https://regex101.com/).
|
||||
In addition, you can make use of the following special tokens:
|
||||
|
||||
- `#player#`: Player name
|
||||
- `#uuid#`: Player UUID (with hyphens)
|
||||
|
||||
For example, if you want every player in your server to have their own subdirectory for storing files that only they
|
||||
can access, plus a shared public directory, you can use the following `allowed-paths` value:
|
||||
```regexp
|
||||
^(private/#player#|public)/
|
||||
```
|
||||
|
||||
That way, the player "john" can see the image file at "private/john/something.jpg", but "jane" cannot.
|
||||
|
||||
> **IMPORTANT!**\
|
||||
> Note that these restrictions **also apply to other entities** like NPCs, command blocks or the server console.
|
||||
> However, special tokens will always match in non-player contexts (e.g., "#player#" will be interpreted as ".+").
|
||||
|
||||
### bStats
|
||||
This library uses bStats to anonymously report the number of installs. If you don't like this, feel free to
|
||||
disable it at any time by adding `enabled: false` to the
|
||||
[bStats configuration file](https://bstats.org/getting-started#:~:text=Disabling%20bStats) (it's ok, no hard feelings).
|
||||
@@ -48,30 +96,136 @@ disable it at any time by adding `enabled: false` to the
|
||||
## Usage
|
||||
This plugin adds the following commands:
|
||||
|
||||
- `/image clear <x z world> <r> [<placed-by>]`: Remove all placed images in a radius of `r` blocks around an origin.
|
||||
- `/image describe`: Show detailed information about a placed image.
|
||||
- `/image download <url> <filename>`: Download an image from a URL and place it in the images directory.
|
||||
- `/image give <player> <filename> <amount> <w> [<h>] [<flags>]`: Give image items that can be placed later to a player.
|
||||
- `/image list [<page>]`: List all available files in the images directory.
|
||||
- `/image place <filename> <width> [<height>]`: Place an image of size `width`x`height` blocks.
|
||||
- `/image place <filename> <w> [<h>] [<flags>]`: Place an image of size `w`x`h` blocks.
|
||||
- `/image remove`: Remove a placed image from the world without deleting the image file.
|
||||
- `/image remove <radius> [<placed-by>]`: Remove all placed images in a radius of `radius` blocks around the player.
|
||||
- `/image top`: List players with the most placed images.
|
||||
|
||||
### Examples
|
||||
|
||||
- Show help\
|
||||
`/image`
|
||||
- Download an image from a URL and save it with another name\
|
||||
`/image download "https://www.example.com/a/b/c/1234.jpg" "imagename.jpg"`
|
||||
- Give 10 image items to "TestPlayer" for the "test.jpg" image (3x5 blocks)\
|
||||
`/image give TestPlayer "test.jpg" 10 3 5`
|
||||
- Give 10 image items to "TestPlayer" that will not drop an image item when removed\
|
||||
`/image give TestPlayer "test.jpg" 10 3 5 -DROP`
|
||||
- Start the dialog to place an image with a width of 3 blocks and auto height\
|
||||
`/image place "imagename.jpg" 3`
|
||||
- Start the dialog to place a 3-blocks wide and 2-blocks high image\
|
||||
`/image place "imagename.jpg" 3 2`
|
||||
- Start the dialog to place an image that glows in the dark\
|
||||
`/image place "imagename.jpg" 3 2 +GLOW`
|
||||
- Start the dialog to remove a placed image while keeping the original file\
|
||||
`/image remove`
|
||||
- Remove all placed images in a radius of 5 blocks around the spawn\
|
||||
`/image clear 0 0 world 5`
|
||||
- Remove all images placed by "EvilPlayer" in a radius of 100 blocks in the nether\
|
||||
`/image clear 50 70 world_nether 100 EvilPlayer`
|
||||
- Remove all legacy placed images (without "placed by" metadata)\
|
||||
`/image clear 0 0 world 9999999 00000000-0000-0000-0000-000000000000`
|
||||
|
||||
## Permissions
|
||||
If you want more granular control over the players who can use a particular set of commands, permissions are the way to go!
|
||||
If you want more granular control over the features exposed to players, permissions are the way to go!
|
||||
|
||||
Yamipa defines the following permissions, each one corresponding to the command with the same name:
|
||||
Yamipa defines the following permissions:
|
||||
|
||||
- `yamipa.describe`
|
||||
- `yamipa.download`
|
||||
- `yamipa.list`
|
||||
- `yamipa.place`
|
||||
- `yamipa.remove`
|
||||
- `yamipa.remove.radius`
|
||||
| Permission | Default | Description |
|
||||
|:----------------------------|:-----------:|:----------------------------------------------------------------|
|
||||
| `yamipa.command.clear` | OPs | To use the `/image clear` command |
|
||||
| `yamipa.command.describe` | OPs | To use the `/image describe` command |
|
||||
| `yamipa.command.download` | OPs | To use the `/image download` command |
|
||||
| `yamipa.command.give` | OPs | To use the `/image give` command |
|
||||
| `yamipa.command.list` | OPs | To use the `/image list` command |
|
||||
| `yamipa.command.place` | OPs | To use the `/image place` command |
|
||||
| `yamipa.command.remove` | OPs | To use the `/image remove` command |
|
||||
| `yamipa.command.remove.own` | OPs | Same as previous, but only for images placed by the same player |
|
||||
| `yamipa.command.top` | OPs | To use the `/image top` command |
|
||||
| `yamipa.item.place` | All players | To place image items |
|
||||
| `yamipa.item.remove` | All players | To remove image items (that have the `REMO` flag) |
|
||||
| `yamipa.item.remove.own` | All players | Same as previous, but only for images placed by the same player |
|
||||
|
||||
By default, only server OPs have all permissions granted. You can change this by using a permission plugin,
|
||||
Note that permissions work **as a tree**. This means that if you grant a permission, by default its children will also
|
||||
be granted. For instance, you can grant access to all commands with `yamipa.command.*`.
|
||||
Similarly, if you grant the `yamipa.command.remove`, the permission `yamipa.command.remove.own` will also be granted.
|
||||
|
||||
You can change which roles or players are granted these commands by using a permission plugin,
|
||||
such as [LuckPerms](https://luckperms.net/) or [GroupManager](https://elgarl.github.io/GroupManager/).
|
||||
|
||||
Both these plugins have been tested to work with Yamipa, although any similar one should work just fine.
|
||||
|
||||
## Player variables
|
||||
Some permission plugins like LuckPerms allow server operators to assign
|
||||
[key-value pairs](https://luckperms.net/wiki/Meta-Commands) to entities as if they were permissions.
|
||||
This is useful for granting different capabilities to different players or groups.
|
||||
|
||||
Yamipa looks for the following variables which, if found, override the default configuration value that applies to all
|
||||
players:
|
||||
|
||||
| Variable (key) | Overrides | Description |
|
||||
|:-----------------------------|:----------------------|:----------------------------------------------------------------------------------|
|
||||
| `yamipa-allowed-paths` | `allowed-paths` | Regular expression that limits which paths in the images directory are accessible |
|
||||
| `yamipa-max-image-dimension` | `max-image-dimension` | Maximum width or height of images and image items issued by this player or group |
|
||||
|
||||
For example, if you want to limit the image size to 5x5 blocks just for the "test" player, you can run this command:
|
||||
```sh
|
||||
# Using LuckPerms
|
||||
/lp user test meta set yamipa-max-image-dimension 5
|
||||
|
||||
# Using GroupManager
|
||||
/manuaddv test yamipa-max-image-dimension 5
|
||||
```
|
||||
|
||||
## Protecting areas
|
||||
In large servers, letting your players place and remove images wherever they want might not be the most sensible idea.
|
||||
For those cases, Yamipa is compatible with other Bukkit plugins that allow creating and managing world areas.
|
||||
If you have one or more of such plugins, Yamipa will automatically apply permissions accordingly and only let players
|
||||
place or remove images where they can place or remove blocks, no additional configuration required.
|
||||
|
||||
The supported plugins are:
|
||||
|
||||
- [WorldGuard](https://enginehub.org/worldguard/)
|
||||
- [GriefPrevention](https://www.spigotmc.org/resources/griefprevention.1884/)
|
||||
- [Towny Advanced](https://townyadvanced.github.io/)
|
||||
- [Lands](https://www.spigotmc.org/resources/53313/)
|
||||
|
||||
## Flags
|
||||
Images from this plugin have a set of boolean attributes called "flags" that modify its behavior. Possible values are:
|
||||
|
||||
- `ANIM` (animatable): Whether an image should be animated or not, useful when you don't want a GIF image to play.
|
||||
- `REMO` (removable): Whether an image can be removed by any player by left-clicking it.
|
||||
- `DROP` (droppable): Whether an image drops an image item when is removed by any player.
|
||||
- `GLOW` (glowing): Whether an image glows in the dark (only works on Minecraft 1.17 and above).
|
||||
|
||||
By default, images placed with the "/image place" command only have the `ANIM` flag.
|
||||
Similarly, image items issued with the "/image give" command have `ANIM`, `REMO` and `DROP` flags.
|
||||
|
||||
Default flags can be modified through the "flags" argument.
|
||||
To add a flag to the default ones use "+{FLAG_NAME}" (e.g. `+GLOW`), and to remove it use "-{FLAG_NAME}" (e.g. `-ANIM`).
|
||||
You can modify multiple flags separating them with commas (e.g. `+GLOW,-ANIM`).
|
||||
|
||||
## How does it work?
|
||||
As you may have already guessed, Minecraft does not support the placing of image files.
|
||||
Yamipa bypasses this limitation by using two built-in features (**item frames and maps**) to render custom images.
|
||||
|
||||
However, because item frames **are entities** (and these cause a lot of lag in a server), Yamipa **does not actually
|
||||
create any entity** in your world. Instead, it tricks your players' clients into thinking there's a placed item frame
|
||||
where there isn't by sending crafted network packets.
|
||||
|
||||
In the case of animated images (i.e. GIFs), Yamipa sends all animation frames to the player (which takes a decent amount
|
||||
of bandwidth) and then sends a tiny packet telling the client to switch to the next animation step (i.e. map) every few
|
||||
milliseconds.
|
||||
|
||||
> **IMPORTANT!**\
|
||||
> Because Yamipa stores a cached copy of an image in memory whenever a player is near it just in case it needs to send
|
||||
> it again without compromising performance, your server **needs to have a bit of spare RAM** to handle this.
|
||||
>
|
||||
> The rule of thumb here is 100K per unique loaded image (1MB for animated images).
|
||||
> Unique means if the same image with same dimensions is placed multiple times it only counts as one instance.
|
||||
|
||||
## License
|
||||
Yamipa is licensed under the [MIT License](LICENSE) and includes third-party software licensed under
|
||||
the MIT License and the [GPLv3 License](https://www.gnu.org/licenses/gpl-3.0.html).
|
||||
Yamipa is licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,45 @@
|
||||
import { placeBlockOnTheFloor, startBot } from './src/bot.js'
|
||||
import { wait } from './src/common.js'
|
||||
import { getRconClient, waitForServer } from './src/rcon.js'
|
||||
|
||||
(async() => {
|
||||
console.log('[AUTOMATA] Waiting for Minecraft server to be ready...')
|
||||
await waitForServer()
|
||||
|
||||
console.log('[AUTOMATA] Logging in as test account...')
|
||||
const bot = await startBot()
|
||||
await bot.look(0, -Math.PI/2)
|
||||
|
||||
console.log('[AUTOMATA] Making test account an OP...')
|
||||
const conn = await getRconClient()
|
||||
await conn.send('op test')
|
||||
await conn.send('gamemode creative test')
|
||||
await wait(2000)
|
||||
|
||||
console.log('[AUTOMATA] Preparing test account...')
|
||||
bot.chat('/clear @p')
|
||||
await wait(2000)
|
||||
bot.chat('/give @p minecraft:dirt')
|
||||
await wait(2000)
|
||||
|
||||
console.log('[AUTOMATA] Placing image on the floor...')
|
||||
bot.chat('/image place pic-1.jpg 4 4')
|
||||
await wait(2000)
|
||||
placeBlockOnTheFloor(bot)
|
||||
await wait(2000)
|
||||
|
||||
console.log('[AUTOMATA] Removing image from floor...')
|
||||
bot.chat('/image remove')
|
||||
await wait(2000)
|
||||
placeBlockOnTheFloor(bot)
|
||||
await wait(2000)
|
||||
|
||||
console.log('[AUTOMATA] Logging off...')
|
||||
bot.end()
|
||||
|
||||
console.log('[AUTOMATA] Stopping server...')
|
||||
await conn.send('stop')
|
||||
await conn.end()
|
||||
|
||||
console.log('[AUTOMATA] Done!')
|
||||
})()
|
||||
Generated
+1672
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@yamipa/automata",
|
||||
"description": "Minecraft Automata for testing Yamipa",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"mineflayer": "^4.20.1",
|
||||
"rcon-client": "^4.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import mineflayer from 'mineflayer'
|
||||
|
||||
/**
|
||||
* @return {Promise<mineflayer.Bot>} Bot instance
|
||||
*/
|
||||
export function startBot() {
|
||||
return new Promise(resolve => {
|
||||
const bot = mineflayer.createBot({
|
||||
host: '127.0.0.1',
|
||||
username: 'test'
|
||||
})
|
||||
bot.once('spawn', () => resolve(bot))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mineflayer.Bot} bot Bot instance
|
||||
*/
|
||||
export function placeBlockOnTheFloor(bot) {
|
||||
bot._client.write('block_place', {
|
||||
location: bot.entity.position.offset(0, -1, 0),
|
||||
direction: 1,
|
||||
hand: 0,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
cursorZ: 0
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Rcon } from 'rcon-client'
|
||||
import { wait } from './common.js'
|
||||
|
||||
const CONNECTION = {
|
||||
host: '127.0.0.1',
|
||||
port: 25575,
|
||||
password: 'rcon',
|
||||
timeout: 1000*20
|
||||
}
|
||||
|
||||
export async function waitForServer() {
|
||||
while (true) {
|
||||
try {
|
||||
const rcon = await Rcon.connect(CONNECTION)
|
||||
await rcon.end()
|
||||
break
|
||||
} catch (_) {
|
||||
// Not ready yet
|
||||
}
|
||||
wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRconClient() {
|
||||
return Rcon.connect(CONNECTION)
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>io.josemmo.bukkit.plugin</groupId>
|
||||
<artifactId>YamipaPlugin</artifactId>
|
||||
<version>1.0.4</version>
|
||||
<version>1.3.2-SNAPSHOT-b2</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>8</maven.compiler.source>
|
||||
@@ -17,50 +17,112 @@
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>spigot-repo</id>
|
||||
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
|
||||
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>dmulloy2-repo</id>
|
||||
<url>https://repo.dmulloy2.net/nexus/repository/public/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>codemc-repo</id>
|
||||
<url>https://repo.codemc.io/repository/maven-public/</url>
|
||||
<layout>default</layout>
|
||||
<id>minecraft-libraries</id>
|
||||
<url>https://libraries.minecraft.net</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>commandapi</id>
|
||||
<url>https://raw.githubusercontent.com/JorelAli/CommandAPI/mvn-repo/</url>
|
||||
<id>enginehub-repo</id>
|
||||
<url>https://maven.enginehub.org/repo/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- https://hub.spigotmc.org/nexus/content/groups/public/org/spigotmc/spigot-api/ -->
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
<artifactId>spigot-api</artifactId>
|
||||
<version>1.16.5-R0.1-SNAPSHOT</version>
|
||||
<version>1.20.6-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://repo.dmulloy2.net/service/rest/repository/browse/public/com/comphenix/protocol/ProtocolLib/ -->
|
||||
<dependency>
|
||||
<groupId>com.comphenix.protocol</groupId>
|
||||
<artifactId>ProtocolLib</artifactId>
|
||||
<version>4.6.1-SNAPSHOT</version>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://github.com/Mojang/brigadier/releases -->
|
||||
<dependency>
|
||||
<groupId>de.tr7zw</groupId>
|
||||
<artifactId>item-nbt-api</artifactId>
|
||||
<version>2.7.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.jorel</groupId>
|
||||
<artifactId>commandapi-shade</artifactId>
|
||||
<version>5.9</version>
|
||||
<groupId>com.mojang</groupId>
|
||||
<artifactId>brigadier</artifactId>
|
||||
<version>1.0.18</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/org.bstats/bstats-bukkit -->
|
||||
<dependency>
|
||||
<groupId>org.bstats</groupId>
|
||||
<artifactId>bstats-bukkit</artifactId>
|
||||
<version>1.8</version>
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/net.luckperms/api -->
|
||||
<dependency>
|
||||
<groupId>net.luckperms</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
<version>5.4</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/ElgarL/groupmanager/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.ElgarL</groupId>
|
||||
<artifactId>groupmanager</artifactId>
|
||||
<version>3.2</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://maven.enginehub.org/artifactory/repo/com/sk89q/worldguard/worldguard-bukkit/ -->
|
||||
<dependency>
|
||||
<groupId>com.sk89q.worldguard</groupId>
|
||||
<artifactId>worldguard-bukkit</artifactId>
|
||||
<version>7.0.9</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/TechFortress/GriefPrevention/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.TechFortress</groupId>
|
||||
<artifactId>GriefPrevention</artifactId>
|
||||
<version>16.18.3</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/TownyAdvanced/towny/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.TownyAdvanced</groupId>
|
||||
<artifactId>towny</artifactId>
|
||||
<version>0.100.2.11</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/Angeschossen/LandsAPI/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.angeschossen</groupId>
|
||||
<artifactId>LandsAPI</artifactId>
|
||||
<version>7.1.12</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/org.jetbrains/annotations -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
<version>24.1.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
@@ -76,7 +138,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<version>3.5.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>shade</id>
|
||||
@@ -90,33 +152,14 @@
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>dev.jorel:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>de.tr7zw:*</artifact>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/**</exclude>
|
||||
<exclude>LICENSE</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.bstats:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>dev.jorel.commandapi-shade</pattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>de.tr7zw.changeme.nbtapi</pattern>
|
||||
<shadedPattern>de.tr7zw.nbtapi</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.bstats</pattern>
|
||||
<shadedPattern>${project.groupId}.bstats</shadedPattern>
|
||||
@@ -127,7 +170,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
@@ -145,5 +188,4 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
File renamed without changes.
@@ -1,36 +1,49 @@
|
||||
package io.josemmo.bukkit.plugin;
|
||||
|
||||
import dev.jorel.commandapi.CommandAPI;
|
||||
import io.josemmo.bukkit.plugin.commands.ImageCommandBridge;
|
||||
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
|
||||
import io.josemmo.bukkit.plugin.renderer.*;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
import org.bstats.charts.SimplePie;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.Color;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class YamipaPlugin extends JavaPlugin {
|
||||
public static final int BSTATS_PLUGIN_ID = 10243;
|
||||
private static YamipaPlugin instance;
|
||||
private static final Logger LOGGER = Logger.getLogger();
|
||||
private static @Nullable YamipaPlugin INSTANCE;
|
||||
private boolean verbose;
|
||||
private ImageStorage storage;
|
||||
private ImageRenderer renderer;
|
||||
private @Nullable ImageStorage storage;
|
||||
private @Nullable ImageRenderer renderer;
|
||||
private @Nullable ItemService itemService;
|
||||
private @Nullable ScheduledExecutorService scheduler;
|
||||
private @Nullable Metrics metrics;
|
||||
|
||||
/**
|
||||
* Get plugin instance
|
||||
* @return Plugin instance
|
||||
*/
|
||||
public static YamipaPlugin getInstance() {
|
||||
return instance;
|
||||
public static @NotNull YamipaPlugin getInstance() {
|
||||
Objects.requireNonNull(INSTANCE, "Cannot get plugin instance if plugin is not running");
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image storage instance
|
||||
* @return Image storage instance
|
||||
*/
|
||||
public ImageStorage getStorage() {
|
||||
public @NotNull ImageStorage getStorage() {
|
||||
Objects.requireNonNull(storage, "Cannot get storage instance if plugin is not running");
|
||||
return storage;
|
||||
}
|
||||
|
||||
@@ -38,141 +51,138 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
* Get image renderer instance
|
||||
* @return Image renderer instance
|
||||
*/
|
||||
public ImageRenderer getRenderer() {
|
||||
public @NotNull ImageRenderer getRenderer() {
|
||||
Objects.requireNonNull(renderer, "Cannot get renderer instance if plugin is not running");
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
* @param path Configuration key path
|
||||
* @param defaultValue Default value
|
||||
* @return Configuration value
|
||||
* Get internal tasks scheduler
|
||||
* @return Tasks scheduler
|
||||
*/
|
||||
private String getConfigValue(String path, String defaultValue) {
|
||||
String value = getConfig().getString(path);
|
||||
return (value == null) ? defaultValue : value;
|
||||
public @NotNull ScheduledExecutorService getScheduler() {
|
||||
Objects.requireNonNull(scheduler, "Cannot get scheduler instance if plugin is not running");
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is verbose
|
||||
* @return Whether plugin is running in verbose mode
|
||||
*/
|
||||
public boolean isVerbose() {
|
||||
return verbose;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
instance = this;
|
||||
CommandAPI.onLoad(false);
|
||||
ImageCommandBridge.register();
|
||||
INSTANCE = this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
CommandAPI.onEnable(this);
|
||||
|
||||
// Initialize logger
|
||||
verbose = getConfig().getBoolean("verbose");
|
||||
verbose = getConfig().getBoolean("verbose", false);
|
||||
if (verbose) {
|
||||
info("Running on VERBOSE mode");
|
||||
LOGGER.info("Running on VERBOSE mode");
|
||||
}
|
||||
|
||||
// Register plugin commands
|
||||
ImageCommandBridge.register(this);
|
||||
|
||||
// Read plugin configuration paths
|
||||
Path basePath = getDataFolder().toPath();
|
||||
String imagesPath = getConfigValue("images-path", "images");
|
||||
String cachePath = getConfigValue("cache-path", "cache");
|
||||
String dataPath = getConfigValue("data-path", "images.dat");
|
||||
String imagesPath = getConfig().getString("images-path", "images");
|
||||
String cachePath = getConfig().getString("cache-path", "cache");
|
||||
String dataPath = getConfig().getString("data-path", "images.dat");
|
||||
|
||||
// Create image storage
|
||||
String allowedPaths = getConfig().getString("allowed-paths", "");
|
||||
storage = new ImageStorage(
|
||||
basePath.resolve(imagesPath).toString(),
|
||||
basePath.resolve(cachePath).toString()
|
||||
basePath.resolve(imagesPath).toAbsolutePath().normalize(),
|
||||
basePath.resolve(cachePath).toAbsolutePath().normalize(),
|
||||
allowedPaths
|
||||
);
|
||||
try {
|
||||
storage.start();
|
||||
} catch (Exception e) {
|
||||
log(Level.SEVERE, "Failed to initialize image storage", e);
|
||||
LOGGER.severe("Failed to initialize image storage", e);
|
||||
}
|
||||
|
||||
// Create image renderer
|
||||
renderer = new ImageRenderer(basePath.resolve(dataPath).toString());
|
||||
boolean animateImages = getConfig().getBoolean("animate-images", true);
|
||||
LOGGER.info(animateImages ? "Enabled image animation support" : "Image animation support is disabled");
|
||||
int maxImageDimension = getConfig().getInt("max-image-dimension", 30);
|
||||
renderer = new ImageRenderer(basePath.resolve(dataPath), animateImages, maxImageDimension);
|
||||
renderer.start();
|
||||
|
||||
// Create image item service
|
||||
itemService = new ItemService();
|
||||
itemService.start();
|
||||
|
||||
// Create thread pool
|
||||
scheduler = Executors.newScheduledThreadPool(6);
|
||||
|
||||
// Warm-up plugin dependencies
|
||||
LOGGER.fine("Waiting for ProtocolLib to be ready...");
|
||||
scheduler.execute(() -> {
|
||||
FakeEntity.waitForProtocolLib();
|
||||
LOGGER.fine("ProtocolLib is now ready");
|
||||
});
|
||||
LOGGER.fine("Triggered map color cache warm-up");
|
||||
FakeMap.pixelToIndex(Color.RED.getRGB()); // Ask for a color index to force cache generation
|
||||
|
||||
// Initialize bStats
|
||||
Metrics metrics = new Metrics(this, BSTATS_PLUGIN_ID);
|
||||
metrics.addCustomChart(new Metrics.SimplePie("number_of_image_files", () -> {
|
||||
int number = storage.size();
|
||||
Function<Integer, String> toStats = number -> {
|
||||
if (number >= 1000) return "1000+";
|
||||
if (number >= 500) return "500-999";
|
||||
if (number >= 100) return "100-499";
|
||||
if (number >= 50) return "50-99";
|
||||
if (number >= 10) return "10-49";
|
||||
return "0-9";
|
||||
}));
|
||||
};
|
||||
metrics = new Metrics(this, BSTATS_PLUGIN_ID);
|
||||
metrics.addCustomChart(new SimplePie("animate_images", () -> animateImages ? "true" : "false"));
|
||||
metrics.addCustomChart(new SimplePie("number_of_image_files", () -> toStats.apply(storage.size())));
|
||||
metrics.addCustomChart(new SimplePie("number_of_placed_images", () -> toStats.apply(renderer.size())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
// Stop metrics
|
||||
if (metrics != null) {
|
||||
metrics.shutdown();
|
||||
metrics = null;
|
||||
}
|
||||
|
||||
// Stop item service
|
||||
if (itemService != null) {
|
||||
itemService.stop();
|
||||
itemService = null;
|
||||
}
|
||||
|
||||
// Stop image renderer
|
||||
if (renderer != null) {
|
||||
renderer.stop();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
// Stop image storage
|
||||
if (storage != null) {
|
||||
storage.stop();
|
||||
storage = null;
|
||||
}
|
||||
|
||||
// Stop internal scheduler
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
|
||||
// Remove Bukkit listeners and tasks
|
||||
HandlerList.unregisterAll(this);
|
||||
Bukkit.getScheduler().cancelTasks(this);
|
||||
storage.stop();
|
||||
renderer.stop();
|
||||
storage = null;
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
* @param e Throwable instance, NULL to ignore
|
||||
*/
|
||||
public void log(Level level, String message, Throwable e) {
|
||||
// Fix log level
|
||||
if (level.intValue() < Level.INFO.intValue()) {
|
||||
if (!verbose) return;
|
||||
level = Level.INFO;
|
||||
}
|
||||
|
||||
// Proxy record to real logger
|
||||
if (e == null) {
|
||||
getLogger().log(level, message);
|
||||
} else {
|
||||
getLogger().log(level, message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
*/
|
||||
public void log(Level level, String message) {
|
||||
log(level, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log severe message
|
||||
* @param message Message
|
||||
*/
|
||||
public void severe(String message) {
|
||||
log(Level.SEVERE, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
*/
|
||||
public void warning(String message) {
|
||||
log(Level.WARNING, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @param message Message
|
||||
*/
|
||||
public void info(String message) {
|
||||
log(Level.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log fine message
|
||||
* @param message Message
|
||||
*/
|
||||
public void fine(String message) {
|
||||
log(Level.FINE, message);
|
||||
// Unlink reference to instance
|
||||
INSTANCE = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package io.josemmo.bukkit.plugin.commands;
|
||||
|
||||
import com.mojang.brigadier.builder.ArgumentBuilder;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import io.josemmo.bukkit.plugin.commands.arguments.Argument;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class Command {
|
||||
private final String name;
|
||||
private final List<Argument> arguments = new ArrayList<>();
|
||||
private Predicate<CommandSender> requirementHandler = __ -> true;
|
||||
private @Nullable BiConsumer<CommandSender, Object[]> executesHandler;
|
||||
private @Nullable BiConsumer<Player, Object[]> executesPlayerHandler;
|
||||
private final List<Command> subcommands = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Command constructor
|
||||
* @param name Command literal name
|
||||
*/
|
||||
public Command(@NotNull String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add argument to command
|
||||
* @param argument Argument instance
|
||||
* @return This instance
|
||||
*/
|
||||
public @NotNull Command withArgument(@NotNull Argument argument) {
|
||||
arguments.add(argument);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add requirement handler to this command
|
||||
* @param handler Handler to determine command availability
|
||||
* @return This instance
|
||||
*/
|
||||
public @NotNull Command withRequirement(@NotNull Predicate<CommandSender> handler) {
|
||||
requirementHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add permission requirement to this command
|
||||
* @param permissions Permission names (match as least one)
|
||||
* @return This instance
|
||||
*/
|
||||
public @NotNull Command withPermission(@NotNull String ...permissions) {
|
||||
return withRequirement(sender -> {
|
||||
for (String permission : permissions) {
|
||||
if (sender.hasPermission(permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add handler that will be executed when the command gets called
|
||||
* @param handler Command handler
|
||||
* @return This instance
|
||||
*/
|
||||
public @NotNull Command executes(@NotNull BiConsumer<CommandSender, Object[]> handler) {
|
||||
executesHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add handler that will be executed when the command gets called by a player
|
||||
* @param handler Command handler
|
||||
* @return This instance
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
public @NotNull Command executesPlayer(@NotNull BiConsumer<Player, Object[]> handler) {
|
||||
executesPlayerHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subcommand to this command
|
||||
* @param name Subcommand literal name
|
||||
* @return The new subcommand
|
||||
*/
|
||||
public @NotNull Command addSubcommand(@NotNull String name) {
|
||||
Command subcommand = new Command(name);
|
||||
subcommands.add(subcommand);
|
||||
return subcommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command and all of its children
|
||||
* @return Literal argument builder instance
|
||||
*/
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public @NotNull LiteralArgumentBuilder<?> build() {
|
||||
LiteralArgumentBuilder<?> root = LiteralArgumentBuilder.literal(name);
|
||||
|
||||
// Add arguments and execution handler
|
||||
buildElement(root, 0);
|
||||
|
||||
// Add subcommands
|
||||
for (Command subcommand : subcommands) {
|
||||
root.then((ArgumentBuilder) subcommand.build());
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private @NotNull ArgumentBuilder buildElement(@NotNull ArgumentBuilder parent, int argIndex) {
|
||||
// Attach requirement handler to each command element
|
||||
parent.requires(source -> {
|
||||
CommandSender sender = Internals.getBukkitSender(source);
|
||||
return requirementHandler.test(sender);
|
||||
});
|
||||
|
||||
// Chain command elements from the bottom-up
|
||||
if (argIndex < arguments.size()) {
|
||||
Argument argument = arguments.get(argIndex);
|
||||
ArgumentBuilder argumentBuilder = argument.build().suggests((ctx, builder) -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
return argument.suggest(sender, builder);
|
||||
});
|
||||
parent.then(buildElement(argumentBuilder, argIndex+1)).executes(ctx -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
sender.sendMessage(ChatColor.RED + "Missing required arguments");
|
||||
return 1;
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
// Attach execution handler to last command element
|
||||
if (executesPlayerHandler != null) {
|
||||
parent.executes(ctx -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
if (sender instanceof Player) {
|
||||
executesPlayerHandler.accept((Player) sender, getArgumentValues(sender, ctx));
|
||||
} else {
|
||||
sender.sendMessage(ChatColor.RED + "Only in-game players can execute this command!");
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
} else if (executesHandler != null) {
|
||||
parent.executes(ctx -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
executesHandler.accept(sender, getArgumentValues(sender, ctx));
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private @NotNull Object[] getArgumentValues(
|
||||
@NotNull CommandSender sender,
|
||||
@NotNull CommandContext<?> ctx
|
||||
) throws CommandSyntaxException {
|
||||
List<Object> argValues = new ArrayList<>();
|
||||
argValues.add(ctx.getNodes().get(0).getNode().getName());
|
||||
for (Argument argument : arguments) {
|
||||
Object value = ctx.getArgument(argument.getName(), Object.class);
|
||||
value = argument.parse(sender, value);
|
||||
argValues.add(value);
|
||||
}
|
||||
return argValues.toArray(new Object[0]);
|
||||
}
|
||||
}
|
||||
@@ -3,43 +3,72 @@ package io.josemmo.bukkit.plugin.commands;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
|
||||
import io.josemmo.bukkit.plugin.renderer.ItemService;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import io.josemmo.bukkit.plugin.utils.SelectBlockTask;
|
||||
import io.josemmo.bukkit.plugin.utils.ActionBar;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Dimension;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
|
||||
public class ImageCommand {
|
||||
public static final int ITEMS_PER_PAGE = 9;
|
||||
private static final int ITEMS_PER_PAGE = 9;
|
||||
private static final int MAX_PATH_DEPTH = 10;
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageCommand");
|
||||
|
||||
public static void showHelp(CommandSender s) {
|
||||
public static void showHelp(@NotNull CommandSender s, @NotNull String commandName) {
|
||||
String cmd = "/" + commandName;
|
||||
s.sendMessage(ChatColor.BOLD + "=== Yamipa Plugin Help ===");
|
||||
s.sendMessage(ChatColor.AQUA + "/image" + ChatColor.RESET + " — Show this help");
|
||||
s.sendMessage(ChatColor.AQUA + "/image describe" + ChatColor.RESET + " — Describe placed image");
|
||||
s.sendMessage(ChatColor.AQUA + "/image download <url> <filename>" + ChatColor.RESET + " — Download image");
|
||||
s.sendMessage(ChatColor.AQUA + "/image list [<page>]" + ChatColor.RESET + " — List all images");
|
||||
s.sendMessage(ChatColor.AQUA + "/image place <filename>" + ChatColor.RESET + " — Place image");
|
||||
s.sendMessage(ChatColor.AQUA + "/image remove" + ChatColor.RESET + " — Remove a single placed image");
|
||||
s.sendMessage(ChatColor.AQUA + "/image remove <radius> [<placed-by>]" + ChatColor.RESET + " — Remove placed images");
|
||||
s.sendMessage(ChatColor.AQUA + cmd + ChatColor.RESET + " - Show this help");
|
||||
if (s.hasPermission("yamipa.command.clear") || s.hasPermission("yamipa.clear")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " clear <x z w> <r> [<player>]" + ChatColor.RESET + " - Remove placed images");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.describe") || s.hasPermission("yamipa.describe")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " describe" + ChatColor.RESET + " - Describe placed image");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.download") || s.hasPermission("yamipa.download")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " download <url> <filename>" + ChatColor.RESET + " - Download image");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.give") || s.hasPermission("yamipa.give")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " give <p> <filename> <#> <w> [<h>] [<f>]" + ChatColor.RESET + " - Give items");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.list") || s.hasPermission("yamipa.list")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " list [<page>]" + ChatColor.RESET + " - List all images");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.place") || s.hasPermission("yamipa.place")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " place <filename> <w> [<h>] [<f>]" + ChatColor.RESET + " - Place image");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.remove.own") || s.hasPermission("yamipa.remove")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " remove" + ChatColor.RESET + " - Remove a single placed image");
|
||||
}
|
||||
if (s.hasPermission("yamipa.command.top") || s.hasPermission("yamipa.top")) {
|
||||
s.sendMessage(ChatColor.AQUA + cmd + " top" + ChatColor.RESET + " - List players with the most images");
|
||||
}
|
||||
}
|
||||
|
||||
public static void listImages(CommandSender sender, int page) {
|
||||
String[] filenames = YamipaPlugin.getInstance().getStorage().getAllFilenames();
|
||||
int numOfImages = filenames.length;
|
||||
public static void listImages(@NotNull CommandSender sender, int page) {
|
||||
ImageStorage storage = YamipaPlugin.getInstance().getStorage();
|
||||
List<String> filenames = storage.getFilenames(sender);
|
||||
int numOfImages = filenames.size();
|
||||
|
||||
// Are there any images available?
|
||||
if (numOfImages == 0) {
|
||||
@@ -61,126 +90,242 @@ public class ImageCommand {
|
||||
sender.sendMessage("=== Page " + page + " out of " + maxPage + " ===");
|
||||
}
|
||||
for (int i=firstImageIndex; i<stopImageIndex; ++i) {
|
||||
sender.sendMessage("" + ChatColor.GOLD + filenames[i]);
|
||||
sender.sendMessage(ChatColor.GOLD + filenames.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
public static void downloadImage(CommandSender sender, String url, String filename) {
|
||||
public static void downloadImage(@NotNull CommandSender sender, @NotNull String rawUrl, @NotNull String filename) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
ImageStorage storage = plugin.getStorage();
|
||||
Path basePath = storage.getBasePath();
|
||||
|
||||
// Resolve destination path
|
||||
Path destPath;
|
||||
try {
|
||||
destPath = basePath.resolve(filename).normalize();
|
||||
} catch (InvalidPathException e) {
|
||||
sender.sendMessage(ChatColor.RED + "Malformed destination path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate destination file
|
||||
Path basePath = Paths.get(plugin.getStorage().getBasePath());
|
||||
Path destPath = basePath.resolve(filename);
|
||||
if (!destPath.getParent().equals(basePath)) {
|
||||
if (!destPath.startsWith(basePath)) {
|
||||
sender.sendMessage(ChatColor.RED + "Not a valid destination filename");
|
||||
return;
|
||||
}
|
||||
if (!storage.isPathAllowed(destPath, sender)) {
|
||||
sender.sendMessage(ChatColor.RED + "Not allowed to download a file here");
|
||||
return;
|
||||
}
|
||||
if (destPath.toFile().exists()) {
|
||||
sender.sendMessage(ChatColor.RED + "There's already a file with that name");
|
||||
return;
|
||||
}
|
||||
int depth = destPath.getNameCount() - basePath.getNameCount();
|
||||
if (depth > MAX_PATH_DEPTH) {
|
||||
sender.sendMessage(ChatColor.RED + "Destination path has too many directories");
|
||||
return;
|
||||
}
|
||||
|
||||
// Download remote URL
|
||||
// Validate and fix remote URL
|
||||
URL url;
|
||||
String referrer = null;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
|
||||
// Giphy.com
|
||||
if (url.getHost().equals("giphy.com")) {
|
||||
String path = url.getPath();
|
||||
String id = path.substring(path.lastIndexOf('-')+1);
|
||||
url = new URL("https://media.giphy.com/media/" + id + "/giphy.gif");
|
||||
referrer = "https://giphy.com/";
|
||||
}
|
||||
|
||||
// Imgur.com
|
||||
if (url.getHost().equals("imgur.com")) {
|
||||
String[] parts = url.getPath().replaceAll("^/|/$", "").split("/");
|
||||
if (parts.length == 2 && (parts[0].equals("a") || parts[0].equals("gallery"))) {
|
||||
url = new URL("https://imgur.com/a/" + parts[1] + "/zip");
|
||||
referrer = "https://imgur.com/a/" + parts[1];
|
||||
} else {
|
||||
url = new URL("https://imgur.com/download/" + parts[parts.length-1] + "/");
|
||||
referrer = "https://imgur.com/" + parts[parts.length-1];
|
||||
}
|
||||
}
|
||||
} catch (MalformedURLException e) {
|
||||
sender.sendMessage(ChatColor.RED + "The remote URL is not valid");
|
||||
return;
|
||||
}
|
||||
|
||||
// Download and validate remote file
|
||||
final URL finalUrl = url;
|
||||
final String finalReferrer = referrer;
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
URLConnection conn = new URL(url).openConnection();
|
||||
URLConnection conn = finalUrl.openConnection();
|
||||
PluginDescriptionFile desc = plugin.getDescription();
|
||||
conn.setRequestProperty("Accept", "*/*");
|
||||
conn.setRequestProperty("User-Agent", desc.getName() + "/" + desc.getVersion());
|
||||
if (finalReferrer != null) {
|
||||
conn.setRequestProperty("Referer", finalReferrer);
|
||||
}
|
||||
|
||||
// Download file
|
||||
sender.sendMessage("Downloading file...");
|
||||
Files.createDirectories(destPath.getParent());
|
||||
Files.copy(conn.getInputStream(), destPath);
|
||||
|
||||
// Validate downloaded file
|
||||
if (ImageIO.read(destPath.toFile()) == null) {
|
||||
throw new IllegalArgumentException("The downloaded file is not a valid image");
|
||||
}
|
||||
|
||||
// Notify sender
|
||||
sender.sendMessage(ChatColor.GREEN + "Done!");
|
||||
} catch (MalformedURLException e) {
|
||||
sender.sendMessage(ChatColor.RED + "The remote URL is not valid");
|
||||
} catch (IOException e) {
|
||||
sender.sendMessage(ChatColor.RED + "An error occurred trying to download the remote file");
|
||||
LOGGER.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName());
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (Files.exists(destPath) && !destPath.toFile().delete()) {
|
||||
LOGGER.warning("Failed to delete corrupted file \"" + destPath + "\"");
|
||||
}
|
||||
sender.sendMessage(ChatColor.RED + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void placeImage(
|
||||
Player player,
|
||||
String filename,
|
||||
@NotNull Player player,
|
||||
@NotNull ImageFile image,
|
||||
int width,
|
||||
int height
|
||||
int height,
|
||||
int flags
|
||||
) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
|
||||
// Get image instance
|
||||
ImageFile image = plugin.getStorage().get(filename);
|
||||
if (image == null) {
|
||||
player.sendMessage(ChatColor.RED + "The requested file does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get image size in blocks
|
||||
Dimension sizeInPixels = image.getSize();
|
||||
if (sizeInPixels == null) {
|
||||
player.sendMessage(ChatColor.RED + "The requested file is not a valid image");
|
||||
return;
|
||||
}
|
||||
if (height == 0) {
|
||||
float imageRatio = (float) sizeInPixels.height / sizeInPixels.width;
|
||||
height = Math.round(width * imageRatio);
|
||||
height = Math.min(height, FakeImage.MAX_DIMENSION);
|
||||
}
|
||||
final int finalHeight = height;
|
||||
final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, player, width) : height;
|
||||
|
||||
// Ask player where to place image
|
||||
SelectBlockTask task = new SelectBlockTask(player);
|
||||
task.onSuccess((location, face) -> {
|
||||
FakeImage existingImage = plugin.getRenderer().getImage(location, face);
|
||||
if (existingImage != null) {
|
||||
ActionBar.send(player, ChatColor.RED + "There's already an image there!");
|
||||
return;
|
||||
}
|
||||
|
||||
ActionBar.send(player, "");
|
||||
Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation());
|
||||
FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation,
|
||||
width, finalHeight, new Date(), player);
|
||||
plugin.getRenderer().addImage(fakeImage);
|
||||
});
|
||||
task.onSuccess((location, face) -> placeImage(player, image, width, finalHeight, flags, location, face));
|
||||
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image placing canceled"));
|
||||
task.run("Right click a block to continue");
|
||||
}
|
||||
|
||||
public static void removeImage(Player player) {
|
||||
public static boolean placeImage(
|
||||
@NotNull Player player,
|
||||
@NotNull ImageFile image,
|
||||
int width,
|
||||
int height,
|
||||
int flags,
|
||||
@NotNull Location location,
|
||||
@NotNull BlockFace face
|
||||
) {
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
|
||||
// Ask user to select fake image
|
||||
// Create new fake image instance
|
||||
Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation());
|
||||
FakeImage fakeImage = new FakeImage(image.getFilename(), location, face, rotation,
|
||||
width, height, new Date(), player, flags);
|
||||
|
||||
// Make sure image can be placed
|
||||
for (Location loc : fakeImage.getAllLocations()) {
|
||||
if (!Permissions.canBuild(player, loc)) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to place an image here!");
|
||||
return false;
|
||||
}
|
||||
if (renderer.getImage(loc, face) != null) {
|
||||
ActionBar.send(player, ChatColor.RED + "There's already an image there!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading status to player
|
||||
ActionBar loadingActionBar = ActionBar.repeat(player, ChatColor.AQUA + "Loading image...");
|
||||
fakeImage.setOnLoadedListener(loadingActionBar::clear);
|
||||
|
||||
// Add fake image to renderer
|
||||
renderer.addImage(fakeImage);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void removeImage(@NotNull Player player) {
|
||||
SelectBlockTask task = new SelectBlockTask(player);
|
||||
task.onSuccess((location, face) -> {
|
||||
FakeImage image = renderer.getImage(location, face);
|
||||
FakeImage image = YamipaPlugin.getInstance().getRenderer().getImage(location, face);
|
||||
if (image == null) {
|
||||
ActionBar.send(player, ChatColor.RED + "That is not a valid image!");
|
||||
} else {
|
||||
ActionBar.send(player, "");
|
||||
renderer.removeImage(image);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check player's command permissions
|
||||
if (
|
||||
!player.getUniqueId().equals(image.getPlacedBy().getUniqueId()) &&
|
||||
!player.hasPermission("yamipa.command.remove") &&
|
||||
!player.hasPermission("yamipa.remove")
|
||||
) {
|
||||
ActionBar.send(player, ChatColor.RED + "You cannot remove images from other players!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to remove image
|
||||
removeImage(player, image);
|
||||
});
|
||||
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image removing canceled"));
|
||||
task.run("Right click an image to continue");
|
||||
}
|
||||
|
||||
public static void removeImagesInRadius(Player player, int radius, Player placedBy) {
|
||||
public static boolean removeImage(@NotNull Player player, @NotNull FakeImage image) {
|
||||
// Check block permissions
|
||||
for (Location loc : image.getAllLocations()) {
|
||||
if (!Permissions.canDestroy(player, loc)) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to remove this image!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger image removal
|
||||
YamipaPlugin.getInstance().getRenderer().removeImage(image);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void clearImages(
|
||||
@NotNull CommandSender sender,
|
||||
@NotNull Location origin,
|
||||
int radius,
|
||||
@Nullable OfflinePlayer placedBy
|
||||
) {
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
|
||||
// Get images in area
|
||||
Location loc = player.getLocation();
|
||||
Set<FakeImage> images = renderer.getImages(
|
||||
player.getWorld(),
|
||||
loc.getBlockX()-radius+1,
|
||||
loc.getBlockX()+radius-1,
|
||||
loc.getBlockZ()-radius+1,
|
||||
loc.getBlockZ()+radius-1
|
||||
Objects.requireNonNull(origin.getWorld()),
|
||||
origin.getBlockX()-radius+1,
|
||||
origin.getBlockX()+radius-1,
|
||||
origin.getBlockZ()-radius+1,
|
||||
origin.getBlockZ()+radius-1
|
||||
);
|
||||
|
||||
// Filter out images not placed by targeted player
|
||||
if (placedBy != null) {
|
||||
UUID target = placedBy.getUniqueId();
|
||||
images.removeIf(image -> !target.equals(image.getPlacedBy().getUniqueId()));
|
||||
}
|
||||
|
||||
// Filter out images outside the permission scope of the sender
|
||||
if (sender instanceof Player) {
|
||||
Player senderAsPlayer = (Player) sender;
|
||||
images.removeIf(image -> {
|
||||
UUID thisTarget = (image.getPlacedBy() == null) ? null : image.getPlacedBy().getUniqueId();
|
||||
return !target.equals(thisTarget);
|
||||
for (Location loc : image.getAllLocations()) {
|
||||
if (!Permissions.canDestroy(senderAsPlayer, loc)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,10 +333,10 @@ public class ImageCommand {
|
||||
for (FakeImage image : images) {
|
||||
renderer.removeImage(image);
|
||||
}
|
||||
player.sendMessage("Removed " + images.size() + " placed image(s)");
|
||||
sender.sendMessage("Removed " + images.size() + " placed image(s)");
|
||||
}
|
||||
|
||||
public static void describeImage(Player player) {
|
||||
public static void describeImage(@NotNull Player player) {
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
|
||||
// Ask user to select fake image
|
||||
@@ -202,21 +347,11 @@ public class ImageCommand {
|
||||
ActionBar.send(player, ChatColor.RED + "That is not a valid image!");
|
||||
return;
|
||||
}
|
||||
ActionBar.send(player, "");
|
||||
|
||||
// Send placed image information to player
|
||||
String dateStr = (image.getPlacedAt() == null) ?
|
||||
ChatColor.GRAY + "Some point in time" :
|
||||
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(image.getPlacedAt());
|
||||
String playerStr;
|
||||
if (image.getPlacedBy() == null) {
|
||||
playerStr = ChatColor.GRAY + "Someone";
|
||||
} else if (image.getPlacedBy().getName() == null) {
|
||||
playerStr = ChatColor.DARK_AQUA + image.getPlacedBy().getUniqueId().toString();
|
||||
} else {
|
||||
playerStr = image.getPlacedBy().getName();
|
||||
}
|
||||
// Separate previous messages
|
||||
player.sendMessage("");
|
||||
|
||||
// Basic information
|
||||
player.sendMessage(ChatColor.GOLD + "Filename: " + ChatColor.RESET + image.getFilename());
|
||||
player.sendMessage(ChatColor.GOLD + "World: " + ChatColor.RESET +
|
||||
image.getLocation().getChunk().getWorld().getName());
|
||||
@@ -228,10 +363,123 @@ public class ImageCommand {
|
||||
player.sendMessage(ChatColor.GOLD + "Rotation: " + ChatColor.RESET + image.getRotation());
|
||||
player.sendMessage(ChatColor.GOLD + "Dimensions: " + ChatColor.RESET +
|
||||
image.getWidth() + "x" + image.getHeight() + " blocks");
|
||||
|
||||
// Speed
|
||||
int delay = image.getDelay() * 50;
|
||||
String delayStr = (delay > 0) ? delay + " ms per step" : ChatColor.GRAY + "N/A";
|
||||
player.sendMessage(ChatColor.GOLD + "Speed: " + ChatColor.RESET + delayStr);
|
||||
|
||||
// Placed At
|
||||
String dateStr = (image.getPlacedAt() == null) ?
|
||||
ChatColor.GRAY + "Some point in time" :
|
||||
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(image.getPlacedAt());
|
||||
player.sendMessage(ChatColor.GOLD + "Placed At: " + ChatColor.RESET + dateStr);
|
||||
|
||||
// Placed By
|
||||
String playerStr;
|
||||
if (image.getPlacedBy().getUniqueId().equals(FakeImage.UNKNOWN_PLAYER_ID)) {
|
||||
playerStr = ChatColor.GRAY + "Someone";
|
||||
} else if (image.getPlacedBy().getName() == null) {
|
||||
playerStr = ChatColor.DARK_AQUA + image.getPlacedBy().getUniqueId().toString();
|
||||
} else {
|
||||
playerStr = image.getPlacedBy().getName();
|
||||
}
|
||||
player.sendMessage(ChatColor.GOLD + "Placed By: " + ChatColor.RESET + playerStr);
|
||||
|
||||
// Flags
|
||||
String flagsStr = "";
|
||||
if (image.hasFlag(FakeImage.FLAG_ANIMATABLE)) {
|
||||
flagsStr += ChatColor.AQUA + "ANIM ";
|
||||
}
|
||||
if (image.hasFlag(FakeImage.FLAG_REMOVABLE)) {
|
||||
flagsStr += ChatColor.RED + "REMO ";
|
||||
}
|
||||
if (image.hasFlag(FakeImage.FLAG_DROPPABLE)) {
|
||||
flagsStr += ChatColor.LIGHT_PURPLE + "DROP ";
|
||||
}
|
||||
if (image.hasFlag(FakeImage.FLAG_GLOWING)) {
|
||||
flagsStr += ChatColor.GREEN + "GLOW ";
|
||||
}
|
||||
if (flagsStr.isEmpty()) {
|
||||
flagsStr = ChatColor.GRAY + "N/A";
|
||||
}
|
||||
player.sendMessage(ChatColor.GOLD + "Flags: " + ChatColor.RESET + flagsStr);
|
||||
});
|
||||
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image describing canceled"));
|
||||
task.run("Right click the image to describe");
|
||||
}
|
||||
|
||||
public static void showTopPlayers(@NotNull CommandSender sender) {
|
||||
UUID senderId = (sender instanceof Player) ? ((Player) sender).getUniqueId() : null;
|
||||
Map<OfflinePlayer, Integer> stats = YamipaPlugin.getInstance().getRenderer().getImagesCountByPlayer();
|
||||
|
||||
// Render header
|
||||
sender.sendMessage("=== Top players with the most placed images ===");
|
||||
if (stats.isEmpty()) {
|
||||
sender.sendMessage(ChatColor.RED + "No one on this server has placed a single image!");
|
||||
return;
|
||||
}
|
||||
|
||||
int rank = 0;
|
||||
int printedLines = 0;
|
||||
boolean hasShownSender = (senderId == null); // Assume sender has already been shown if it's not a player
|
||||
for (Map.Entry<OfflinePlayer, Integer> item : stats.entrySet()) {
|
||||
OfflinePlayer player = item.getKey();
|
||||
int value = item.getValue();
|
||||
++rank;
|
||||
|
||||
// Skip line if irrelevant
|
||||
if (player.getUniqueId().equals(senderId)) {
|
||||
hasShownSender = true;
|
||||
} else if (!hasShownSender && printedLines == ITEMS_PER_PAGE-1) {
|
||||
continue; // Leave last line empty for sender rank
|
||||
} else if (printedLines >= ITEMS_PER_PAGE) {
|
||||
break; // Stop printing players when chat is filled
|
||||
}
|
||||
|
||||
// Prepare player name or UUID
|
||||
String playerName = (player.getName() == null) ?
|
||||
ChatColor.GOLD + player.getUniqueId().toString() :
|
||||
ChatColor.GREEN + player.getName();
|
||||
|
||||
// Render player line
|
||||
sender.sendMessage(
|
||||
"" + ChatColor.BOLD + (rank > 1000 ? "1000+" : rank) + ChatColor.RESET + ". " +
|
||||
playerName + ChatColor.RESET +
|
||||
ChatColor.GRAY + " - " + value + " " + (value == 1 ? "image" : "images")
|
||||
);
|
||||
++printedLines;
|
||||
}
|
||||
}
|
||||
|
||||
public static void giveImageItems(
|
||||
@NotNull CommandSender sender,
|
||||
@NotNull Player player,
|
||||
@NotNull ImageFile image,
|
||||
int amount,
|
||||
int width,
|
||||
int height,
|
||||
int flags
|
||||
) {
|
||||
// Get image size in blocks
|
||||
Dimension sizeInPixels = image.getSize();
|
||||
if (sizeInPixels == null) {
|
||||
sender.sendMessage(ChatColor.RED + "The requested file is not a valid image");
|
||||
return;
|
||||
}
|
||||
if (height == 0) {
|
||||
height = FakeImage.getProportionalHeight(sizeInPixels, sender, width);
|
||||
}
|
||||
|
||||
// Create item stack
|
||||
ItemStack itemStack = ItemService.getImageItem(image, amount, width, height, flags);
|
||||
|
||||
// Add item stack to player's inventory
|
||||
player.getInventory().addItem(itemStack);
|
||||
sender.sendMessage(
|
||||
ChatColor.ITALIC + "Added " + amount + " " +
|
||||
(amount == 1 ? "image item" : "image items") +
|
||||
" to " + player.getName() + "'s inventory"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,217 @@
|
||||
package io.josemmo.bukkit.plugin.commands;
|
||||
|
||||
import dev.jorel.commandapi.CommandAPICommand;
|
||||
import dev.jorel.commandapi.arguments.Argument;
|
||||
import dev.jorel.commandapi.arguments.IntegerArgument;
|
||||
import dev.jorel.commandapi.arguments.PlayerArgument;
|
||||
import dev.jorel.commandapi.arguments.TextArgument;
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.commands.arguments.*;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ImageCommandBridge {
|
||||
private static final String COMMAND_NAME = "yamipa";
|
||||
private static final String[] COMMAND_ALIASES = new String[] {"image", "images"};
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageCommandBridge");
|
||||
|
||||
/**
|
||||
* Register command
|
||||
* @param plugin Plugin instance
|
||||
*/
|
||||
public static void register() {
|
||||
CommandAPICommand group = new CommandAPICommand("image").withAliases("yamipa", "images");
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public static void register(@NotNull YamipaPlugin plugin) {
|
||||
CommandDispatcher dispatcher = Internals.getDispatcher();
|
||||
|
||||
// Register command
|
||||
LiteralCommandNode<?> commandNode = getRootCommand().build().build();
|
||||
dispatcher.getRoot().addChild(commandNode);
|
||||
|
||||
// Register aliases
|
||||
for (String alias : COMMAND_ALIASES) {
|
||||
LiteralCommandNode<?> aliasNode = new LiteralCommandNode(
|
||||
alias,
|
||||
commandNode.getCommand(),
|
||||
commandNode.getRequirement(),
|
||||
commandNode,
|
||||
commandNode.getRedirectModifier(),
|
||||
commandNode.isFork()
|
||||
);
|
||||
dispatcher.getRoot().addChild(aliasNode);
|
||||
}
|
||||
LOGGER.fine("Registered plugin command and aliases");
|
||||
|
||||
// Fix "minecraft.command.*" permissions
|
||||
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> {
|
||||
fixPermissions(COMMAND_NAME);
|
||||
for (String alias : COMMAND_ALIASES) {
|
||||
fixPermissions(alias);
|
||||
}
|
||||
LOGGER.fine("Fixed command permissions");
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
private static @NotNull Command getRootCommand() {
|
||||
Command root = new Command(COMMAND_NAME);
|
||||
|
||||
// Help command
|
||||
group.executes((sender, __) -> {
|
||||
ImageCommand.showHelp(sender);
|
||||
});
|
||||
root.withPermission(
|
||||
"yamipa.command.clear", "yamipa.clear",
|
||||
"yamipa.command.describe", "yamipa.describe",
|
||||
"yamipa.command.download", "yamipa.download",
|
||||
"yamipa.command.give", "yamipa.give",
|
||||
"yamipa.command.list", "yamipa.list",
|
||||
"yamipa.command.place", "yamipa.place",
|
||||
"yamipa.command.remove.own", "yamipa.remove",
|
||||
"yamipa.command.top", "yamipa.top"
|
||||
)
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.showHelp(sender, (String) args[0]);
|
||||
});
|
||||
|
||||
// List command
|
||||
CommandAPICommand list = new CommandAPICommand("list")
|
||||
.withPermission("yamipa.list")
|
||||
// Clear command
|
||||
root.addSubcommand("clear")
|
||||
.withPermission("yamipa.command.clear", "yamipa.clear")
|
||||
.withArgument(new IntegerArgument("x"))
|
||||
.withArgument(new IntegerArgument("z"))
|
||||
.withArgument(new WorldArgument("world"))
|
||||
.withArgument(new IntegerArgument("radius", 1))
|
||||
.withArgument(new PlacedByArgument("placedBy"))
|
||||
.executes((sender, args) -> {
|
||||
Location origin = new Location((World) args[3], (int) args[1], 0, (int) args[2]);
|
||||
ImageCommand.clearImages(sender, origin, (int) args[4], (OfflinePlayer) args[5]);
|
||||
});
|
||||
root.addSubcommand("clear")
|
||||
.withPermission("yamipa.command.clear", "yamipa.clear")
|
||||
.withArgument(new IntegerArgument("x"))
|
||||
.withArgument(new IntegerArgument("z"))
|
||||
.withArgument(new WorldArgument("world"))
|
||||
.withArgument(new IntegerArgument("radius", 1))
|
||||
.executes((sender, args) -> {
|
||||
Location origin = new Location((World) args[3], (int) args[1], 0, (int) args[2]);
|
||||
ImageCommand.clearImages(sender, origin, (int) args[4], null);
|
||||
});
|
||||
|
||||
// Describe command
|
||||
root.addSubcommand("describe")
|
||||
.withPermission("yamipa.command.describe", "yamipa.describe")
|
||||
.executesPlayer((player, __) -> {
|
||||
ImageCommand.describeImage(player);
|
||||
});
|
||||
|
||||
// Download command
|
||||
root.addSubcommand("download")
|
||||
.withPermission("yamipa.command.download", "yamipa.download")
|
||||
.withArgument(new StringArgument("url"))
|
||||
.withArgument(new StringArgument("filename"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.downloadImage(sender, (String) args[1], (String) args[2]);
|
||||
});
|
||||
|
||||
// Give subcommand
|
||||
root.addSubcommand("give")
|
||||
.withPermission("yamipa.command.give", "yamipa.give")
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_GIVE_FLAGS))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
(int) args[4], (int) args[5], (int) args[6]);
|
||||
});
|
||||
root.addSubcommand("give")
|
||||
.withPermission("yamipa.command.give", "yamipa.give")
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
(int) args[4], (int) args[5], FakeImage.DEFAULT_GIVE_FLAGS);
|
||||
});
|
||||
root.addSubcommand("give")
|
||||
.withPermission("yamipa.command.give", "yamipa.give")
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
(int) args[4], 0, FakeImage.DEFAULT_GIVE_FLAGS);
|
||||
});
|
||||
|
||||
// List subcommand
|
||||
root.addSubcommand("list")
|
||||
.withPermission("yamipa.command.list", "yamipa.list")
|
||||
.withArgument(new IntegerArgument("page", 1))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.listImages(sender, (int) args[1]);
|
||||
});
|
||||
root.addSubcommand("list")
|
||||
.withPermission("yamipa.command.list", "yamipa.list")
|
||||
.executes((sender, __) -> {
|
||||
boolean isPlayer = (sender instanceof Player);
|
||||
ImageCommand.listImages(sender, isPlayer ? 1 : 0);
|
||||
});
|
||||
group.withSubcommand(list);
|
||||
|
||||
// List command (with page argument)
|
||||
CommandAPICommand listWithPage = new CommandAPICommand("list")
|
||||
.withPermission("yamipa.list")
|
||||
.withArguments(new IntegerArgument("page", 1))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.listImages(sender, (int) args[0]);
|
||||
// Place subcommand
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_PLACE_FLAGS))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], (int) args[4]);
|
||||
});
|
||||
group.withSubcommand(listWithPage);
|
||||
|
||||
// Download command
|
||||
CommandAPICommand download = new CommandAPICommand("download")
|
||||
.withPermission("yamipa.download")
|
||||
.withArguments(new TextArgument("url"))
|
||||
.withArguments(new TextArgument("filename"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.downloadImage(sender, (String) args[0], (String) args[1]);
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3],
|
||||
FakeImage.DEFAULT_PLACE_FLAGS);
|
||||
});
|
||||
group.withSubcommand(download);
|
||||
|
||||
// Place command (only width)
|
||||
CommandAPICommand placeOnlyWidth = new CommandAPICommand("place")
|
||||
.withPermission("yamipa.place")
|
||||
.withArguments(getFilenameArgument())
|
||||
.withArguments(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.executesPlayer((sender, args) -> {
|
||||
ImageCommand.placeImage(sender, (String) args[0], (int) args[1], 0);
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0,
|
||||
FakeImage.DEFAULT_PLACE_FLAGS);
|
||||
});
|
||||
group.withSubcommand(placeOnlyWidth);
|
||||
|
||||
// Place command
|
||||
CommandAPICommand place = new CommandAPICommand("place")
|
||||
.withPermission("yamipa.place")
|
||||
.withArguments(getFilenameArgument())
|
||||
.withArguments(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArguments(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
|
||||
.executesPlayer((sender, args) -> {
|
||||
ImageCommand.placeImage(sender, (String) args[0], (int) args[1], (int) args[2]);
|
||||
// Remove subcommand
|
||||
root.addSubcommand("remove")
|
||||
.withPermission("yamipa.command.remove.own", "yamipa.remove")
|
||||
.executesPlayer((player, __) -> {
|
||||
ImageCommand.removeImage(player);
|
||||
});
|
||||
group.withSubcommand(place);
|
||||
|
||||
// Remove command
|
||||
CommandAPICommand remove = new CommandAPICommand("remove")
|
||||
.withPermission("yamipa.remove")
|
||||
.executesPlayer((sender, __) -> {
|
||||
ImageCommand.removeImage(sender);
|
||||
// Top subcommand
|
||||
root.addSubcommand("top")
|
||||
.withPermission("yamipa.command.top", "yamipa.top")
|
||||
.executes((sender, __) -> {
|
||||
ImageCommand.showTopPlayers(sender);
|
||||
});
|
||||
group.withSubcommand(remove);
|
||||
|
||||
// Remove command (in radius)
|
||||
CommandAPICommand removeInRadius = new CommandAPICommand("remove")
|
||||
.withPermission("yamipa.remove.radius")
|
||||
.withArguments(new IntegerArgument("radius", 1))
|
||||
.executesPlayer((sender, args) -> {
|
||||
ImageCommand.removeImagesInRadius(sender, (int) args[0], null);
|
||||
});
|
||||
group.withSubcommand(removeInRadius);
|
||||
|
||||
// Remove command (in filtered radius)
|
||||
CommandAPICommand removeInFilteredRadius = new CommandAPICommand("remove")
|
||||
.withPermission("yamipa.remove.radius")
|
||||
.withArguments(new IntegerArgument("radius", 1))
|
||||
.withArguments(new PlayerArgument("placedBy"))
|
||||
.executesPlayer((sender, args) -> {
|
||||
ImageCommand.removeImagesInRadius(sender, (int) args[0], (Player) args[1]);
|
||||
});
|
||||
group.withSubcommand(removeInFilteredRadius);
|
||||
|
||||
// Describe command
|
||||
CommandAPICommand describe = new CommandAPICommand("describe")
|
||||
.withPermission("yamipa.describe")
|
||||
.executesPlayer((sender, __) -> {
|
||||
ImageCommand.describeImage(sender);
|
||||
});
|
||||
group.withSubcommand(describe);
|
||||
|
||||
// Commit commands
|
||||
group.register();
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename argument
|
||||
* @return Filename argument
|
||||
*/
|
||||
private static Argument getFilenameArgument() {
|
||||
return new TextArgument("filename")
|
||||
.overrideSuggestions(__ -> YamipaPlugin.getInstance().getStorage().getAllFilenames());
|
||||
private static void fixPermissions(@NotNull String commandName) {
|
||||
org.bukkit.command.Command command = Internals.getCommandMap().getCommand(commandName);
|
||||
if (command != null) { // Command may have been aliased to null in "commands.yml"
|
||||
command.setPermission(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.LiteralMessage;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public abstract class Argument {
|
||||
protected final String name;
|
||||
|
||||
/**
|
||||
* Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public Argument(@NotNull String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get argument name
|
||||
* @return Argument name
|
||||
*/
|
||||
public @NotNull String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build argument
|
||||
* @return Argument builder instance
|
||||
*/
|
||||
public abstract @NotNull RequiredArgumentBuilder<?, ?> build();
|
||||
|
||||
/**
|
||||
* Suggest argument values
|
||||
* @param sender Command sender
|
||||
* @param builder Suggestions builder instance
|
||||
* @return Suggestions
|
||||
*/
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse argument value
|
||||
* @param sender Command sender
|
||||
* @param rawValue Argument value provided by Brigadier
|
||||
* @return Parsed argument value
|
||||
*/
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new syntax exception
|
||||
* @param message Message to show
|
||||
* @return Syntax exception
|
||||
*/
|
||||
protected @NotNull CommandSyntaxException newException(@NotNull String message) {
|
||||
return new SimpleCommandExceptionType(new LiteralMessage(message)).create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ImageDimensionArgument extends IntegerArgument {
|
||||
/**
|
||||
* Dimension Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public ImageDimensionArgument(@NotNull String name) {
|
||||
super(name, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
int maxDimension = FakeImage.getMaxImageDimension(sender);
|
||||
int value = (int) rawValue;
|
||||
if (value > maxDimension) {
|
||||
throw newException("Image cannot be larger than " + maxDimension + "x" + maxDimension);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ImageFileArgument extends StringArgument {
|
||||
/**
|
||||
* Image File Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public ImageFileArgument(@NotNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
for (String filename : YamipaPlugin.getInstance().getStorage().getFilenames(sender)) {
|
||||
builder.suggest(StringArgumentType.escapeIfRequired(filename));
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
String filename = (String) rawValue;
|
||||
ImageStorage storage = YamipaPlugin.getInstance().getStorage();
|
||||
ImageFile imageFile = storage.get(filename);
|
||||
if (imageFile == null || !storage.isPathAllowed(filename, sender)) {
|
||||
throw newException("Image file does not exist");
|
||||
}
|
||||
return imageFile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class ImageFlagsArgument extends Argument {
|
||||
private final int defaultFlags;
|
||||
|
||||
/**
|
||||
* Image File Argument constructor
|
||||
* @param name Argument name
|
||||
* @param defaultFlags Default flags
|
||||
*/
|
||||
public ImageFlagsArgument(@NotNull String name, int defaultFlags) {
|
||||
super(name);
|
||||
this.defaultFlags = defaultFlags;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
String input = builder.getRemaining().replaceAll("[^A-Z+\\-,]", "");
|
||||
int lastIndex = Collections.max(
|
||||
Arrays.asList(input.lastIndexOf(","), input.lastIndexOf("+"), input.lastIndexOf("-"))
|
||||
);
|
||||
input = input.substring(0, lastIndex+1);
|
||||
|
||||
// Add suggestions
|
||||
String[] values = new String[] {"ANIM", "REMO", "DROP", "GLOW"};
|
||||
for (String value : values) {
|
||||
builder.suggest(input + value);
|
||||
}
|
||||
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
String value = (String) rawValue;
|
||||
int parsedValue = defaultFlags;
|
||||
|
||||
// Load flags from parts
|
||||
String[] parts = value.split(",");
|
||||
for (String part : parts) {
|
||||
// Parse modifier
|
||||
String modifier = "";
|
||||
if (part.startsWith("+") || part.startsWith("-")) {
|
||||
modifier = part.substring(0, 1);
|
||||
part = part.substring(1);
|
||||
}
|
||||
|
||||
// Parse flag name
|
||||
int flag;
|
||||
switch (part) {
|
||||
case "ANIM":
|
||||
flag = FakeImage.FLAG_ANIMATABLE;
|
||||
break;
|
||||
case "REMO":
|
||||
flag = FakeImage.FLAG_REMOVABLE;
|
||||
break;
|
||||
case "DROP":
|
||||
flag = FakeImage.FLAG_DROPPABLE;
|
||||
break;
|
||||
case "GLOW":
|
||||
flag = FakeImage.FLAG_GLOWING;
|
||||
break;
|
||||
default:
|
||||
throw newException("Unrecognized flag \"" + part + "\"");
|
||||
}
|
||||
|
||||
// Apply flag to value
|
||||
if (modifier.equals("+")) {
|
||||
parsedValue |= flag;
|
||||
} else if (modifier.equals("-")) {
|
||||
parsedValue &= ~flag;
|
||||
} else {
|
||||
parsedValue = flag;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class IntegerArgument extends Argument {
|
||||
private final int min;
|
||||
private final int max;
|
||||
|
||||
/**
|
||||
* Integer Argument constructor
|
||||
* @param name Argument name
|
||||
* @param min Minimum allowed value
|
||||
* @param max Maximum allowed value
|
||||
*/
|
||||
public IntegerArgument(@NotNull String name, int min, int max) {
|
||||
super(name);
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer Argument constructor
|
||||
* @param name Argument name
|
||||
* @param min Minimum allowed value
|
||||
*/
|
||||
public IntegerArgument(@NotNull String name, int min) {
|
||||
this(name, min, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public IntegerArgument(@NotNull String name) {
|
||||
this(name, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return RequiredArgumentBuilder.argument(name, IntegerArgumentType.integer(min, max));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class OnlinePlayerArgument extends StringArgument {
|
||||
/**
|
||||
* Online player argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public OnlinePlayerArgument(@NotNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
Player player = getAllowedValues().get((String) rawValue);
|
||||
if (player == null) {
|
||||
throw newException("Expected online player (name or UUID)");
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Player> getAllowedValues() {
|
||||
Map<String, Player> values = new HashMap<>();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
values.put(player.getUniqueId().toString(), player);
|
||||
values.put(player.getName(), player);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class PlacedByArgument extends StringArgument {
|
||||
/**
|
||||
* Placed by Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public PlacedByArgument(@NotNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
OfflinePlayer player = getAllowedValues().get((String) rawValue);
|
||||
if (player == null) {
|
||||
throw newException("Expected player with placed images (name or UUID)");
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private @NotNull Map<String, OfflinePlayer> getAllowedValues() {
|
||||
Map<String, OfflinePlayer> values = new HashMap<>();
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
for (OfflinePlayer player : renderer.getPlayersWithPlacedImages()) {
|
||||
values.put(player.getUniqueId().toString(), player);
|
||||
if (player.getName() != null) {
|
||||
values.put(player.getName(), player);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class StringArgument extends Argument {
|
||||
/**
|
||||
* String Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public StringArgument(@NotNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return RequiredArgumentBuilder.argument(name, StringArgumentType.string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public class WorldArgument extends StringArgument {
|
||||
/**
|
||||
* World Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public WorldArgument(@NotNull String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
builder.suggest(world.getName());
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
World world = Bukkit.getWorld((String) rawValue);
|
||||
if (world == null) {
|
||||
throw newException("Expected world name");
|
||||
}
|
||||
return world;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.josemmo.bukkit.plugin.packets;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import com.comphenix.protocol.wrappers.WrappedChatComponent;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ActionBarPacket extends PacketContainer {
|
||||
private static final boolean USE_TITLE = (Internals.MINECRAFT_VERSION < 17);
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public ActionBarPacket() {
|
||||
super(USE_TITLE ? PacketType.Play.Server.TITLE : PacketType.Play.Server.SET_ACTION_BAR_TEXT);
|
||||
if (USE_TITLE) {
|
||||
getTitleActions().write(0, EnumWrappers.TitleAction.ACTIONBAR);
|
||||
}
|
||||
}
|
||||
|
||||
public @NotNull ActionBarPacket setText(@NotNull String text) {
|
||||
getChatComponents().write(0, WrappedChatComponent.fromText(text));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.josemmo.bukkit.plugin.packets;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.Collections;
|
||||
|
||||
public class DestroyEntityPacket extends PacketContainer {
|
||||
public DestroyEntityPacket() {
|
||||
super(PacketType.Play.Server.ENTITY_DESTROY);
|
||||
}
|
||||
|
||||
public @NotNull DestroyEntityPacket setId(int id) {
|
||||
if (Internals.MINECRAFT_VERSION < 17) { // Minecraft 1.16.x
|
||||
getIntegerArrays().write(0, new int[]{id});
|
||||
} else if (Internals.MINECRAFT_VERSION < 17.1) { // Minecraft 1.17
|
||||
getIntegers().write(0, id);
|
||||
} else { // Minecraft 1.17.x
|
||||
getIntLists().write(0, Collections.singletonList(id));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.josemmo.bukkit.plugin.packets;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.wrappers.BukkitConverters;
|
||||
import com.comphenix.protocol.wrappers.WrappedDataValue;
|
||||
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.bukkit.Rotation;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class EntityMetadataPacket extends PacketContainer {
|
||||
private static final boolean USE_DATA_WATCHER;
|
||||
private static final int ITEM_INDEX;
|
||||
private static final int ROTATION_INDEX;
|
||||
private final WrappedDataWatcher dataWatcher = new WrappedDataWatcher(); // For <= 1.19.2
|
||||
private final List<WrappedDataValue> values = new ArrayList<>(); // For >= 1.19.3
|
||||
|
||||
static {
|
||||
USE_DATA_WATCHER = (Internals.MINECRAFT_VERSION < 19.3f);
|
||||
ITEM_INDEX = (Internals.MINECRAFT_VERSION < 17) ? 7 : 8;
|
||||
ROTATION_INDEX = ITEM_INDEX + 1;
|
||||
}
|
||||
|
||||
public EntityMetadataPacket() {
|
||||
super(PacketType.Play.Server.ENTITY_METADATA);
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket setId(int id) {
|
||||
getIntegers().write(0, id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket setFlags(byte flags) {
|
||||
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.get(Byte.class);
|
||||
if (USE_DATA_WATCHER) {
|
||||
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(0, serializer), flags);
|
||||
} else {
|
||||
values.add(new WrappedDataValue(0, serializer, flags));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket setInvisible(boolean invisible) {
|
||||
int flags = invisible ? 0x20 : 0x00;
|
||||
return setFlags((byte) flags);
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket setItem(@NotNull ItemStack item) {
|
||||
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.getItemStackSerializer(false);
|
||||
if (USE_DATA_WATCHER) {
|
||||
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(ITEM_INDEX, serializer), item);
|
||||
} else {
|
||||
values.add(new WrappedDataValue(
|
||||
ITEM_INDEX,
|
||||
serializer,
|
||||
BukkitConverters.getItemStackConverter().getGeneric(item)
|
||||
));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket setRotation(@NotNull Rotation rotation) {
|
||||
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.get(Integer.class);
|
||||
if (USE_DATA_WATCHER) {
|
||||
dataWatcher.setObject(
|
||||
new WrappedDataWatcher.WrappedDataWatcherObject(ROTATION_INDEX, serializer),
|
||||
rotation.ordinal()
|
||||
);
|
||||
} else {
|
||||
values.add(new WrappedDataValue(ROTATION_INDEX, serializer, rotation.ordinal()));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull EntityMetadataPacket build() {
|
||||
if (USE_DATA_WATCHER) {
|
||||
getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
|
||||
} else {
|
||||
getDataValueCollectionModifier().write(0, values);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.josemmo.bukkit.plugin.packets;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.reflect.StructureModifier;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public class MapDataPacket extends PacketContainer {
|
||||
private static final int LOCKED_INDEX;
|
||||
private @Nullable StructureModifier<?> mapDataModifier;
|
||||
|
||||
static {
|
||||
LOCKED_INDEX = (Internals.MINECRAFT_VERSION < 17) ? 1 : 0;
|
||||
}
|
||||
|
||||
public MapDataPacket() {
|
||||
super(PacketType.Play.Server.MAP);
|
||||
getModifier().writeDefaults();
|
||||
|
||||
if (Internals.MINECRAFT_VERSION < 17) {
|
||||
// Disable tracking position
|
||||
getBooleans().write(0, false);
|
||||
} else {
|
||||
// Create modifier for map data instance
|
||||
Class<?> mapDataType = getModifier().getField(4).getType();
|
||||
Object mapDataInstance = getModifier().read(4);
|
||||
mapDataModifier = new StructureModifier<>(mapDataType).withTarget(mapDataInstance);
|
||||
}
|
||||
}
|
||||
|
||||
public @NotNull MapDataPacket setId(int id) {
|
||||
getIntegers().write(0, id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull MapDataPacket setArea(int columns, int rows, int x, int z) {
|
||||
if (mapDataModifier == null) {
|
||||
getIntegers()
|
||||
.write(1, x)
|
||||
.write(2, z)
|
||||
.write(3, columns)
|
||||
.write (4, rows);
|
||||
} else {
|
||||
mapDataModifier.withType(Integer.TYPE)
|
||||
.write(0, x)
|
||||
.write(1, z)
|
||||
.write(2, columns)
|
||||
.write(3, rows);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull MapDataPacket setScale(int scale) {
|
||||
getBytes().write(0, (byte) scale);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull MapDataPacket setLocked(boolean locked) {
|
||||
getBooleans().write(LOCKED_INDEX, locked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull MapDataPacket setPixels(byte[] pixels) {
|
||||
if (mapDataModifier == null) {
|
||||
getByteArrays().write(0, pixels);
|
||||
} else {
|
||||
mapDataModifier.withType(byte[].class).write(0, pixels);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.josemmo.bukkit.plugin.packets;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.UUID;
|
||||
|
||||
public class SpawnEntityPacket extends PacketContainer {
|
||||
private static final boolean ROTATION_AS_BYTES;
|
||||
private static final int DATA_INDEX;
|
||||
|
||||
static {
|
||||
if (Internals.MINECRAFT_VERSION < 19) {
|
||||
ROTATION_AS_BYTES = false;
|
||||
DATA_INDEX = 6;
|
||||
} else {
|
||||
DATA_INDEX = 4;
|
||||
ROTATION_AS_BYTES = true;
|
||||
}
|
||||
}
|
||||
|
||||
public SpawnEntityPacket() {
|
||||
super(PacketType.Play.Server.SPAWN_ENTITY);
|
||||
getIntegers()
|
||||
.write(1, 0)
|
||||
.write(2, 0)
|
||||
.write(3, 0);
|
||||
getUUIDs()
|
||||
.write(0, UUID.randomUUID());
|
||||
}
|
||||
|
||||
public @NotNull SpawnEntityPacket setEntityType(@NotNull EntityType type) {
|
||||
getEntityTypeModifier().write(0, type);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull SpawnEntityPacket setId(int id) {
|
||||
getIntegers().write(0, id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull SpawnEntityPacket setRotation(int pitch, int yaw) {
|
||||
if (ROTATION_AS_BYTES) {
|
||||
getBytes()
|
||||
.write(0, (byte) pitch)
|
||||
.write(1, (byte) yaw);
|
||||
} else {
|
||||
getIntegers()
|
||||
.write(4, pitch)
|
||||
.write(5, yaw);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull SpawnEntityPacket setData(int data) {
|
||||
getIntegers().write(DATA_INDEX, data);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NotNull SpawnEntityPacket setPosition(double x, double y, double z) {
|
||||
getDoubles()
|
||||
.write(0, x)
|
||||
.write(1, y)
|
||||
.write(2, z);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,124 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.injector.player.PlayerInjectionHandler;
|
||||
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
import java.util.logging.Level;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
public abstract class FakeEntity {
|
||||
protected static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private static final ProtocolManager connection = ProtocolLibrary.getProtocolManager();
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeEntity");
|
||||
private static final ProtocolManager CONNECTION = ProtocolLibrary.getProtocolManager();
|
||||
private static @Nullable PlayerInjectionHandler PLAYER_INJECTION_HANDLER;
|
||||
private static boolean READY = false;
|
||||
|
||||
static {
|
||||
try {
|
||||
for (Field field : CONNECTION.getClass().getDeclaredFields()) {
|
||||
if (field.getType().equals(PlayerInjectionHandler.class)) {
|
||||
field.setAccessible(true);
|
||||
PLAYER_INJECTION_HANDLER = (PlayerInjectionHandler) field.get(CONNECTION);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (PLAYER_INJECTION_HANDLER == null) {
|
||||
throw new RuntimeException("No valid candidate field found in ProtocolManager");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to get PlayerInjectionHandler from ProtocolLib", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for ProtocolLib to be ready
|
||||
* <p>
|
||||
* NOTE: Will wait synchronously, blocking the invoker thread
|
||||
*/
|
||||
public static synchronized void waitForProtocolLib() {
|
||||
if (READY) {
|
||||
// ProtocolLib is ready
|
||||
return;
|
||||
}
|
||||
|
||||
int retry = 0;
|
||||
while (true) {
|
||||
try {
|
||||
WrappedDataWatcher.Registry.get(Byte.class);
|
||||
READY = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (++retry > 20) {
|
||||
// Exhausted max. retries
|
||||
throw e;
|
||||
}
|
||||
tryToSleep(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to sleep
|
||||
* <p>
|
||||
* NOTE: Will wait synchronously, blocking the invoker thread
|
||||
* @param ms Delay in milliseconds
|
||||
*/
|
||||
protected static void tryToSleep(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (Exception __) {
|
||||
// Fail silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to send packet
|
||||
* @param player Player who will receive the packet
|
||||
* @param packet Packet to send
|
||||
*/
|
||||
protected static void tryToSendPacket(Player player, PacketContainer packet) {
|
||||
protected static void tryToSendPacket(@NotNull Player player, @NotNull PacketContainer packet) {
|
||||
try {
|
||||
connection.sendServerPacket(player, packet);
|
||||
if (PLAYER_INJECTION_HANDLER == null) { // Use single-threaded packet sending if reflection failed
|
||||
CONNECTION.sendServerPacket(player, packet);
|
||||
} else { // Use non-blocking packet sending if available (faster, the expected case)
|
||||
PLAYER_INJECTION_HANDLER.sendServerPacket(player, packet, null, false);
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
// Server is shutting down and cannot send the packet, ignore
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Failed to send FakeEntity packet", e);
|
||||
LOGGER.severe("Failed to send FakeEntity packet", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to send several packets
|
||||
* @param player Player who will receive the packets
|
||||
* @param packets Packets to send
|
||||
*/
|
||||
protected static void tryToSendPackets(@NotNull Player player, @NotNull Iterable<PacketContainer> packets) {
|
||||
if (Internals.MINECRAFT_VERSION < 19.4f) {
|
||||
for (PacketContainer packet : packets) {
|
||||
tryToSendPacket(player, packet);
|
||||
}
|
||||
} else {
|
||||
PacketContainer container = new PacketContainer(PacketType.Play.Server.BUNDLE);
|
||||
container.getPacketBundles().write(0, packets);
|
||||
tryToSendPacket(player, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to run asynchronous task
|
||||
* @param callback Callback to execute
|
||||
*/
|
||||
protected static void tryToRunAsyncTask(@NotNull Runnable callback) {
|
||||
YamipaPlugin.getInstance().getScheduler().execute(callback);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,46 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.storage.CachedMapsFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.DirectionUtils;
|
||||
import org.bukkit.Bukkit;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.Rotation;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.util.Vector;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.*;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class FakeImage extends FakeEntity {
|
||||
public static final int MAX_DIMENSION = 30; // In blocks
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeImage");
|
||||
|
||||
// Image constants
|
||||
public static final int MAX_STEPS = 500; // For animated images
|
||||
public static final int MIN_DELAY = 1; // Minimum step delay in 50ms intervals (50ms / 50ms)
|
||||
public static final int MAX_DELAY = 50; // Maximum step delay in 50ms intervals (5000ms / 50ms)
|
||||
public static final UUID UNKNOWN_PLAYER_ID = new UUID(0, 0);
|
||||
|
||||
// Flags
|
||||
public static final int FLAG_ANIMATABLE = 1; // Whether image is allowed to animate multiple steps
|
||||
public static final int FLAG_REMOVABLE = 2; // Whether image can be removed by a player using the interact button
|
||||
public static final int FLAG_DROPPABLE = 4; // Whether image will drop an image item when removed by a player
|
||||
public static final int FLAG_GLOWING = 8; // Whether image glows in the dark
|
||||
public static final int DEFAULT_PLACE_FLAGS = FLAG_ANIMATABLE;
|
||||
public static final int DEFAULT_GIVE_FLAGS = FLAG_ANIMATABLE | FLAG_REMOVABLE | FLAG_DROPPABLE;
|
||||
|
||||
// Instance properties
|
||||
private final String filename;
|
||||
private final Location location;
|
||||
private final BlockFace face;
|
||||
@@ -27,8 +49,20 @@ public class FakeImage extends FakeEntity {
|
||||
private final int height;
|
||||
private final Date placedAt;
|
||||
private final OfflinePlayer placedBy;
|
||||
private final int flags;
|
||||
private final BiFunction<Integer, Integer, Vector> getLocationVector;
|
||||
private FakeItemFrame[][] frames;
|
||||
private final Set<Player> observingPlayers = new HashSet<>();
|
||||
private @Nullable Runnable onLoadedListener = null;
|
||||
|
||||
// Generated values
|
||||
private boolean loading = false;
|
||||
private FakeItemFrame[] frames = null;
|
||||
private int delay = 0; // Delay between steps in 50ms intervals, "0" for N/A
|
||||
private int numOfSteps = -1; // Total number of animation steps
|
||||
|
||||
// Animation task attributes
|
||||
private @Nullable ScheduledFuture<?> task;
|
||||
private int currentStep = -1; // Current animation step
|
||||
|
||||
/**
|
||||
* Get image rotation from player eyesight
|
||||
@@ -36,7 +70,7 @@ public class FakeImage extends FakeEntity {
|
||||
* @param location Player eye location
|
||||
* @return Image rotation
|
||||
*/
|
||||
public static Rotation getRotationFromPlayerEyesight(BlockFace face, Location location) {
|
||||
public static @NotNull Rotation getRotationFromPlayerEyesight(@NotNull BlockFace face, @NotNull Location location) {
|
||||
// Images placed on N/S/E/W faces never have rotation
|
||||
if (face != BlockFace.UP && face != BlockFace.DOWN) {
|
||||
return Rotation.NONE;
|
||||
@@ -56,20 +90,61 @@ public class FakeImage extends FakeEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum image dimension
|
||||
* @param sender Sender instance
|
||||
* @return Maximum image dimension in blocks
|
||||
*/
|
||||
public static int getMaxImageDimension(@NotNull CommandSender sender) {
|
||||
if (sender instanceof Player) {
|
||||
String rawValue = Permissions.getVariable("yamipa-max-image-dimension", (Player) sender);
|
||||
if (rawValue != null) {
|
||||
try {
|
||||
return Integer.parseInt(rawValue);
|
||||
} catch (NumberFormatException __) {
|
||||
LOGGER.warning("Max. image dimension for " + sender + " is not a valid integer: \"" + rawValue + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
return YamipaPlugin.getInstance().getRenderer().getMaxImageDimension();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proportional height
|
||||
* @param sizeInPixels Image file dimension in pixels
|
||||
* @param sender Sender instance
|
||||
* @param width Desired width in blocks
|
||||
* @return Height in blocks (capped at maximum image dimension for sender)
|
||||
*/
|
||||
public static int getProportionalHeight(@NotNull Dimension sizeInPixels, @NotNull CommandSender sender, int width) {
|
||||
float imageRatio = (float) sizeInPixels.height / sizeInPixels.width;
|
||||
int height = Math.round(width * imageRatio);
|
||||
height = Math.min(height, getMaxImageDimension(sender));
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param filename Image filename
|
||||
* @param location Top-left corner where image will be placed
|
||||
* @param face Block face
|
||||
* @param rotation Image rotation
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @param placedAt Placed at
|
||||
* @param placedBy Placed by
|
||||
* @param filename Image filename
|
||||
* @param location Top-left corner where image will be placed
|
||||
* @param face Block face
|
||||
* @param rotation Image rotation
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @param placedAt Placed at
|
||||
* @param placedBy Placed by
|
||||
* @param flags Flags
|
||||
*/
|
||||
public FakeImage(
|
||||
String filename, Location location, BlockFace face, Rotation rotation, int width, int height,
|
||||
Date placedAt, OfflinePlayer placedBy
|
||||
@NotNull String filename,
|
||||
@NotNull Location location,
|
||||
@NotNull BlockFace face,
|
||||
@NotNull Rotation rotation,
|
||||
int width,
|
||||
int height,
|
||||
@Nullable Date placedAt,
|
||||
@NotNull OfflinePlayer placedBy,
|
||||
int flags
|
||||
) {
|
||||
this.filename = filename;
|
||||
this.location = location;
|
||||
@@ -79,6 +154,7 @@ public class FakeImage extends FakeEntity {
|
||||
this.height = height;
|
||||
this.placedAt = placedAt;
|
||||
this.placedBy = placedBy;
|
||||
this.flags = flags;
|
||||
|
||||
// Define function for retrieving item frame positional vector from <row,column> pair
|
||||
if (face == BlockFace.SOUTH) {
|
||||
@@ -111,14 +187,14 @@ public class FakeImage extends FakeEntity {
|
||||
}
|
||||
}
|
||||
|
||||
plugin.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
|
||||
LOGGER.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image filename
|
||||
* @return Image filename
|
||||
*/
|
||||
public String getFilename() {
|
||||
public @NotNull String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
@@ -126,15 +202,15 @@ public class FakeImage extends FakeEntity {
|
||||
* Get image file instance
|
||||
* @return Image file instance or NULL if not found
|
||||
*/
|
||||
public ImageFile getFile() {
|
||||
return plugin.getStorage().get(filename);
|
||||
public @Nullable ImageFile getFile() {
|
||||
return YamipaPlugin.getInstance().getStorage().get(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location instance
|
||||
* Get top-left corner of image
|
||||
* @return Location instance
|
||||
*/
|
||||
public Location getLocation() {
|
||||
public @NotNull Location getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
@@ -142,7 +218,7 @@ public class FakeImage extends FakeEntity {
|
||||
* Get image block face
|
||||
* @return Image block face
|
||||
*/
|
||||
public BlockFace getBlockFace() {
|
||||
public @NotNull BlockFace getBlockFace() {
|
||||
return face;
|
||||
}
|
||||
|
||||
@@ -150,7 +226,7 @@ public class FakeImage extends FakeEntity {
|
||||
* Get image rotation
|
||||
* @return Image rotation
|
||||
*/
|
||||
public Rotation getRotation() {
|
||||
public @NotNull Rotation getRotation() {
|
||||
return rotation;
|
||||
}
|
||||
|
||||
@@ -174,7 +250,7 @@ public class FakeImage extends FakeEntity {
|
||||
* Get placed at date
|
||||
* @return Placed at date
|
||||
*/
|
||||
public Date getPlacedAt() {
|
||||
public @Nullable Date getPlacedAt() {
|
||||
return placedAt;
|
||||
}
|
||||
|
||||
@@ -182,16 +258,60 @@ public class FakeImage extends FakeEntity {
|
||||
* Get placed by player
|
||||
* @return Placed by player instance
|
||||
*/
|
||||
public OfflinePlayer getPlacedBy() {
|
||||
public @NotNull OfflinePlayer getPlacedBy() {
|
||||
return placedBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get world area ID where the top left corner of this image is located
|
||||
* @return World area ID
|
||||
* Get flags
|
||||
* @return Flags
|
||||
*/
|
||||
public WorldAreaId getWorldAreaId() {
|
||||
return WorldAreaId.fromLocation(location);
|
||||
public int getFlags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has flag
|
||||
* @param flag Flag to check
|
||||
* @return Whether instance has given flag or not
|
||||
*/
|
||||
public boolean hasFlag(int flag) {
|
||||
return ((flags & flag) == flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image delay
|
||||
* @return Image delay in 50ms intervals
|
||||
*/
|
||||
public int getDelay() {
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all block locations covered by the image
|
||||
* @return Array of location instances
|
||||
*/
|
||||
public @NotNull Location[] getAllLocations() {
|
||||
Location[] locations = new Location[width*height];
|
||||
for (int row=0; row<height; row++) {
|
||||
for (int col=0; col<width; col++) {
|
||||
locations[row*width + col] = location.clone().add(getLocationVector.apply(col, row));
|
||||
}
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the world area IDs where this image is located
|
||||
* @return Array of world area IDs
|
||||
*/
|
||||
public @NotNull WorldAreaId[] getWorldAreaIds() {
|
||||
Set<WorldAreaId> corners = new HashSet<>();
|
||||
corners.add(WorldAreaId.fromLocation(location));
|
||||
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(width-1, 0))));
|
||||
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(0, height-1))));
|
||||
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(width-1, height-1))));
|
||||
return corners.toArray(new WorldAreaId[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +320,8 @@ public class FakeImage extends FakeEntity {
|
||||
* @param face Block face
|
||||
* @return TRUE for contained, FALSE otherwise
|
||||
*/
|
||||
public boolean contains(Location location, BlockFace face) {
|
||||
@SuppressWarnings("RedundantIfStatement")
|
||||
public boolean contains(@NotNull Location location, @NotNull BlockFace face) {
|
||||
// Is point facing the same plane as the image?
|
||||
if (face != this.face) {
|
||||
return false;
|
||||
@@ -225,76 +346,219 @@ public class FakeImage extends FakeEntity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load item frames and maps
|
||||
* Set on loaded listener
|
||||
* <p>
|
||||
* Defines a single-use listener that gets called when the fake image finishes loading,
|
||||
* at which point the listener is automatically unregistered.
|
||||
* @param onLoadedListener Listener
|
||||
*/
|
||||
public void setOnLoadedListener(@NotNull Runnable onLoadedListener) {
|
||||
this.onLoadedListener = onLoadedListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load generated instance attributes
|
||||
*/
|
||||
private void load() {
|
||||
ImageFile file = getFile();
|
||||
FakeMap[][] maps;
|
||||
|
||||
// Get maps to use
|
||||
FakeMap[][][] maps;
|
||||
if (file == null) {
|
||||
maps = FakeMap.getErrorMatrix(width, height);
|
||||
plugin.warning("File \"" + filename + "\" does not exist");
|
||||
LOGGER.warning("File \"" + filename + "\" does not exist");
|
||||
} else {
|
||||
maps = file.getMapsAndSubscribe(this);
|
||||
CachedMapsFile cachedMapsFile = file.getMapsAndSubscribe(this);
|
||||
maps = cachedMapsFile.getMaps();
|
||||
delay = cachedMapsFile.getDelay();
|
||||
}
|
||||
numOfSteps = maps[0][0].length;
|
||||
|
||||
// Generate frames
|
||||
frames = new FakeItemFrame[width][height];
|
||||
FakeItemFrame[] newFrames = new FakeItemFrame[width*height];
|
||||
boolean glowing = hasFlag(FLAG_GLOWING);
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
Location frameLocation = location.clone().add(getLocationVector.apply(col, row));
|
||||
frames[col][row] = new FakeItemFrame(frameLocation, face, rotation, maps[col][row]);
|
||||
newFrames[height*col+row] = new FakeItemFrame(frameLocation, face, rotation, glowing, maps[col][row]);
|
||||
}
|
||||
}
|
||||
frames = newFrames;
|
||||
|
||||
// Start animation task (if needed)
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
boolean isAnimationEnabled = plugin.getRenderer().isAnimationEnabled();
|
||||
if (isAnimationEnabled && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
|
||||
task = plugin.getScheduler().scheduleAtFixedRate(
|
||||
this::nextStep,
|
||||
0,
|
||||
delay*50L,
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
LOGGER.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
|
||||
}
|
||||
|
||||
// Notify listener
|
||||
if (onLoadedListener != null) {
|
||||
onLoadedListener.run();
|
||||
onLoadedListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn image for a player
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void spawn(Player player) {
|
||||
BukkitScheduler scheduler = Bukkit.getScheduler();
|
||||
scheduler.runTaskAsynchronously(plugin, () -> {
|
||||
if (frames == null) load();
|
||||
scheduler.runTask(plugin, () -> {
|
||||
for (FakeItemFrame[] col : frames) {
|
||||
for (FakeItemFrame frame : col) {
|
||||
frame.spawn(player);
|
||||
}
|
||||
public void spawn(@NotNull Player player) {
|
||||
LOGGER.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
|
||||
|
||||
// Send pixels if instance is already loaded
|
||||
if (frames != null) {
|
||||
spawnOnceLoaded(player);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for instance to load if loading
|
||||
if (loading) {
|
||||
tryToRunAsyncTask(() -> {
|
||||
while (loading) {
|
||||
tryToSleep(5);
|
||||
}
|
||||
spawnOnceLoaded(player);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Instance needs to be loaded
|
||||
loading = true;
|
||||
tryToRunAsyncTask(() -> {
|
||||
waitForProtocolLib();
|
||||
load();
|
||||
loading = false;
|
||||
spawnOnceLoaded(player);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy image for a player
|
||||
* Spawn image for a player (once instance has been loaded)
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void destroy(Player player) {
|
||||
if (frames == null) return;
|
||||
private void spawnOnceLoaded(@NotNull Player player) {
|
||||
String playerName = player.getName();
|
||||
observingPlayers.add(player);
|
||||
|
||||
// Get frame IDs
|
||||
int[] frameIds = Stream.of(frames).flatMap(Stream::of).mapToInt(FakeItemFrame::getId).toArray();
|
||||
// Prepare packets to send
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
packets.add(frame.getSpawnPacket());
|
||||
packets.addAll(frame.getRenderPackets(player, 0));
|
||||
LOGGER.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName);
|
||||
}
|
||||
|
||||
// Send destroy packet
|
||||
PacketContainer destroyPacket = new PacketContainer(PacketType.Play.Server.ENTITY_DESTROY);
|
||||
destroyPacket.getIntegerArrays().write(0, frameIds);
|
||||
tryToSendPacket(player, destroyPacket);
|
||||
// Send packets
|
||||
tryToSendPackets(player, packets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache
|
||||
* <p>
|
||||
* Removes all item frames associated with this image.
|
||||
* Destroy image for all players
|
||||
*/
|
||||
public void invalidate() {
|
||||
public void destroy() {
|
||||
if (frames != null) {
|
||||
destroy(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy image a player
|
||||
* @param player Player instance or NULL for all observing players
|
||||
*/
|
||||
public void destroy(@Nullable Player player) {
|
||||
LOGGER.fine(
|
||||
"Received request to destroy FakeImage#(" + location + "," + face + ") for " +
|
||||
(player == null ? "all players" : "Player#" + player.getName())
|
||||
);
|
||||
|
||||
// Send packets to destroy item frames
|
||||
if (frames != null) {
|
||||
Set<Player> targets = (player == null) ? observingPlayers : Collections.singleton(player);
|
||||
for (Player target : targets) {
|
||||
String targetName = target.getName();
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
packets.add(frame.getDestroyPacket());
|
||||
LOGGER.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName);
|
||||
}
|
||||
tryToSendPackets(target, packets);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove player from observing players
|
||||
if (player == null) {
|
||||
observingPlayers.clear();
|
||||
} else {
|
||||
observingPlayers.remove(player);
|
||||
}
|
||||
|
||||
// Invalidate instance (if no more players)
|
||||
if (observingPlayers.isEmpty()) {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify player quit from server
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void notifyPlayerQuit(@NotNull Player player) {
|
||||
observingPlayers.remove(player);
|
||||
if (observingPlayers.isEmpty()) {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate instance
|
||||
* <p>
|
||||
* Removes all item frames associated with this image, among other things.
|
||||
*/
|
||||
private void invalidate() {
|
||||
// Destroy animation task
|
||||
if (task != null) {
|
||||
task.cancel(true);
|
||||
task = null;
|
||||
currentStep = -1;
|
||||
LOGGER.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")");
|
||||
}
|
||||
|
||||
// Free array of fake item frames
|
||||
frames = null;
|
||||
loading = false;
|
||||
LOGGER.fine("Invalidated FakeImage#(" + location + "," + face + ")");
|
||||
|
||||
// Notify invalidation to source ImageFile
|
||||
ImageFile file = getFile();
|
||||
if (file != null) {
|
||||
file.unsubscribe(this);
|
||||
}
|
||||
}
|
||||
|
||||
plugin.fine("Invalidated FakeImage#(" + location + "," + face + ")");
|
||||
/**
|
||||
* Send next animation step to all registered players
|
||||
*/
|
||||
private void nextStep() {
|
||||
currentStep = (currentStep + 1) % numOfSteps;
|
||||
try {
|
||||
for (Player player : observingPlayers) {
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
packets.addAll(frame.getRenderPackets(player, currentStep));
|
||||
}
|
||||
tryToSendPackets(player, packets);
|
||||
}
|
||||
} catch (ConcurrentModificationException e) {
|
||||
// We can safely ignore this exception as it will just result
|
||||
// in a dropped step (all `observingPlayers` modifications are
|
||||
// called from the same thread).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
|
||||
import de.tr7zw.changeme.nbtapi.NBTItem;
|
||||
import com.comphenix.protocol.utility.MinecraftReflection;
|
||||
import com.comphenix.protocol.wrappers.nbt.NbtCompound;
|
||||
import com.comphenix.protocol.wrappers.nbt.NbtFactory;
|
||||
import io.josemmo.bukkit.plugin.packets.DestroyEntityPacket;
|
||||
import io.josemmo.bukkit.plugin.packets.EntityMetadataPacket;
|
||||
import io.josemmo.bukkit.plugin.packets.SpawnEntityPacket;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Rotation;
|
||||
@@ -11,27 +16,31 @@ import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import java.util.UUID;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class FakeItemFrame extends FakeEntity {
|
||||
public static final int MIN_FRAME_ID = Integer.MAX_VALUE / 4;
|
||||
public static final int MAX_FRAME_ID = Integer.MAX_VALUE;
|
||||
private static final AtomicInteger lastFrameId = new AtomicInteger(MAX_FRAME_ID);
|
||||
private static final boolean SUPPORTS_GLOWING = Internals.MINECRAFT_VERSION >= 17;
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeItemFrame");
|
||||
private static final AtomicInteger LAST_FRAME_ID = new AtomicInteger(MAX_FRAME_ID);
|
||||
private final int id;
|
||||
private final Location location;
|
||||
private final BlockFace face;
|
||||
private final Rotation rotation;
|
||||
private final FakeMap map;
|
||||
private PacketContainer[] spawnPackets;
|
||||
private final boolean glowing;
|
||||
private final FakeMap[] maps;
|
||||
|
||||
/**
|
||||
* Get next unused item frame ID
|
||||
* @return Next unused item frame ID
|
||||
*/
|
||||
private static int getNextId() {
|
||||
return lastFrameId.updateAndGet(lastId -> {
|
||||
if (lastId >= MAX_FRAME_ID) {
|
||||
return LAST_FRAME_ID.updateAndGet(lastId -> {
|
||||
if (lastId == MAX_FRAME_ID) {
|
||||
return MIN_FRAME_ID;
|
||||
}
|
||||
return lastId + 1;
|
||||
@@ -43,31 +52,38 @@ public class FakeItemFrame extends FakeEntity {
|
||||
* @param location Frame location
|
||||
* @param face Block face
|
||||
* @param rotation Frame rotation
|
||||
* @param map Fake map to render
|
||||
* @param glowing Whether is glowing or regular frame
|
||||
* @param maps Fake maps to animate
|
||||
*/
|
||||
public FakeItemFrame(Location location, BlockFace face, Rotation rotation, FakeMap map) {
|
||||
public FakeItemFrame(
|
||||
@NotNull Location location,
|
||||
@NotNull BlockFace face,
|
||||
@NotNull Rotation rotation,
|
||||
boolean glowing,
|
||||
@NotNull FakeMap[] maps
|
||||
) {
|
||||
this.id = getNextId();
|
||||
this.location = location;
|
||||
this.face = face;
|
||||
this.rotation = rotation;
|
||||
this.map = map;
|
||||
plugin.fine("Created FakeItemFrame#" + this.id + " using FakeMap#" + this.map.getId());
|
||||
this.glowing = glowing;
|
||||
this.maps = maps;
|
||||
LOGGER.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item frame ID
|
||||
* @return Item frame ID
|
||||
* Get frame ID
|
||||
* @return Frame ID
|
||||
*/
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate spawn packets
|
||||
* Get entity spawn packet
|
||||
* @return Spawn packet
|
||||
*/
|
||||
private void generateSpawnPackets() {
|
||||
spawnPackets = new PacketContainer[2];
|
||||
|
||||
public @NotNull SpawnEntityPacket getSpawnPacket() {
|
||||
// Calculate frame position in relation to target block
|
||||
double x = location.getBlockX();
|
||||
double y = location.getBlockY();
|
||||
@@ -106,64 +122,55 @@ public class FakeItemFrame extends FakeEntity {
|
||||
}
|
||||
|
||||
// Create item frame entity
|
||||
PacketContainer framePacket = new PacketContainer(PacketType.Play.Server.SPAWN_ENTITY);
|
||||
framePacket.getEntityTypeModifier()
|
||||
.write(0, EntityType.ITEM_FRAME);
|
||||
framePacket.getIntegers()
|
||||
.write(0, id)
|
||||
.write(1, 0)
|
||||
.write(2, 0)
|
||||
.write(3, 0)
|
||||
.write(4, pitch)
|
||||
.write(5, yaw)
|
||||
.write(6, orientation);
|
||||
framePacket.getUUIDs()
|
||||
.write(0, UUID.randomUUID());
|
||||
framePacket.getDoubles()
|
||||
.write(0, x)
|
||||
.write(1, y)
|
||||
.write(2, z);
|
||||
spawnPackets[0] = framePacket;
|
||||
SpawnEntityPacket framePacket = new SpawnEntityPacket();
|
||||
framePacket.setId(id)
|
||||
.setEntityType((glowing && SUPPORTS_GLOWING) ? EntityType.GLOW_ITEM_FRAME : EntityType.ITEM_FRAME)
|
||||
.setPosition(x, y, z)
|
||||
.setRotation(pitch, yaw)
|
||||
.setData(orientation);
|
||||
|
||||
// Create and attach filled map
|
||||
NBTItem mapNbt = new NBTItem(new ItemStack(Material.FILLED_MAP));
|
||||
mapNbt.setInteger("map", map.getId());
|
||||
ItemStack mapItemStack = mapNbt.getItem();
|
||||
|
||||
WrappedDataWatcher dataWatcher = new WrappedDataWatcher();
|
||||
WrappedDataWatcher.Serializer byteSerializer = WrappedDataWatcher.Registry.get(Byte.class);
|
||||
WrappedDataWatcher.Serializer itemStackSerializer = WrappedDataWatcher.Registry.getItemStackSerializer(false);
|
||||
WrappedDataWatcher.Serializer integerSerializer = WrappedDataWatcher.Registry.get(Integer.class);
|
||||
dataWatcher.setObject(
|
||||
new WrappedDataWatcher.WrappedDataWatcherObject(0, byteSerializer),
|
||||
(byte) 0x20 // Invisible
|
||||
);
|
||||
dataWatcher.setObject(
|
||||
new WrappedDataWatcher.WrappedDataWatcherObject(7, itemStackSerializer),
|
||||
mapItemStack
|
||||
);
|
||||
dataWatcher.setObject(
|
||||
new WrappedDataWatcher.WrappedDataWatcherObject(8, integerSerializer),
|
||||
rotation.ordinal()
|
||||
);
|
||||
|
||||
PacketContainer mapPacket = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA);
|
||||
mapPacket.getIntegers().write(0, id);
|
||||
mapPacket.getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
|
||||
spawnPackets[1] = mapPacket;
|
||||
return framePacket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn item frame in player's client
|
||||
* @param player Player instance
|
||||
* Get frame of animation packets
|
||||
* @param player Player who is expected to receive packets (for caching reasons)
|
||||
* @param step Map step
|
||||
*/
|
||||
public void spawn(Player player) {
|
||||
if (spawnPackets == null) {
|
||||
generateSpawnPackets();
|
||||
public @NotNull List<PacketContainer> getRenderPackets(@NotNull Player player, int step) {
|
||||
List<PacketContainer> packets = new ArrayList<>(2);
|
||||
|
||||
// Enqueue map pixels packet (if needed)
|
||||
boolean mustSendPixels = maps[step].requestResend(player);
|
||||
if (mustSendPixels) {
|
||||
packets.add(maps[step].getPixelsPacket());
|
||||
}
|
||||
for (PacketContainer packet : spawnPackets) {
|
||||
tryToSendPacket(player, packet);
|
||||
}
|
||||
map.sendPixels(player);
|
||||
|
||||
// Create and attach filled map
|
||||
ItemStack itemStack = MinecraftReflection.getBukkitItemStack(new ItemStack(Material.FILLED_MAP));
|
||||
NbtCompound itemStackNbt = NbtFactory.ofCompound("tag");
|
||||
itemStackNbt.put("map", maps[step].getId());
|
||||
NbtFactory.setItemTag(itemStack, itemStackNbt);
|
||||
|
||||
// Build entity metadata packet
|
||||
EntityMetadataPacket metadataPacket = new EntityMetadataPacket();
|
||||
metadataPacket.setId(id)
|
||||
.setInvisible(true)
|
||||
.setItem(itemStack)
|
||||
.setRotation(rotation)
|
||||
.build();
|
||||
packets.add(metadataPacket);
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get destroy item frame packet
|
||||
* @return Destroy packet
|
||||
*/
|
||||
public @NotNull DestroyEntityPacket getDestroyPacket() {
|
||||
DestroyEntityPacket destroyPacket = new DestroyEntityPacket();
|
||||
destroyPacket.setId(id);
|
||||
return destroyPacket;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import io.josemmo.bukkit.plugin.packets.MapDataPacket;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.map.MapPalette;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
@@ -14,11 +16,12 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class FakeMap extends FakeEntity {
|
||||
public static final int DIMENSION = 128;
|
||||
public static final int MIN_MAP_ID = 10000;
|
||||
public static final int MAX_MAP_ID = 32767;
|
||||
public static final int RESEND_THRESHOLD = 5; // Seconds after sending pixels when resending should be avoided
|
||||
private static final AtomicInteger lastMapId = new AtomicInteger(-1);
|
||||
private static FakeMap errorInstance;
|
||||
private static final int MIN_MAP_ID = Integer.MAX_VALUE / 4;
|
||||
private static final int MAX_MAP_ID = Integer.MAX_VALUE;
|
||||
private static final int RESEND_THRESHOLD = 60*5; // Seconds after sending pixels when resending should be avoided
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeMap");
|
||||
private static final AtomicInteger LAST_MAP_ID = new AtomicInteger(MIN_MAP_ID);
|
||||
private static @Nullable FakeMap ERROR_INSTANCE;
|
||||
private final int id;
|
||||
private final byte[] pixels;
|
||||
private final ConcurrentMap<UUID, Long> lastPlayerSendTime = new ConcurrentHashMap<>();
|
||||
@@ -28,8 +31,8 @@ public class FakeMap extends FakeEntity {
|
||||
* @return Next unused map ID
|
||||
*/
|
||||
private static int getNextId() {
|
||||
return lastMapId.updateAndGet(lastId -> {
|
||||
if (lastId <= MIN_MAP_ID) {
|
||||
return LAST_MAP_ID.updateAndGet(lastId -> {
|
||||
if (lastId == MIN_MAP_ID) {
|
||||
return MAX_MAP_ID;
|
||||
}
|
||||
return lastId - 1;
|
||||
@@ -50,26 +53,26 @@ public class FakeMap extends FakeEntity {
|
||||
* Get map instance to show in case of error
|
||||
* @return Error instance
|
||||
*/
|
||||
private static FakeMap getErrorInstance() {
|
||||
if (errorInstance == null) {
|
||||
private static @NotNull FakeMap getErrorInstance() {
|
||||
if (ERROR_INSTANCE == null) {
|
||||
byte[] pixels = new byte[DIMENSION * DIMENSION];
|
||||
Arrays.fill(pixels, pixelToIndex(Color.RED.getRGB()));
|
||||
errorInstance = new FakeMap(pixels);
|
||||
ERROR_INSTANCE = new FakeMap(pixels);
|
||||
}
|
||||
return errorInstance;
|
||||
return ERROR_INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matrix of error maps
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Error matrix
|
||||
* @return Fake maps
|
||||
*/
|
||||
public static FakeMap[][] getErrorMatrix(int width, int height) {
|
||||
FakeMap errorMap = getErrorInstance();
|
||||
FakeMap[][] matrix = new FakeMap[width][height];
|
||||
for (FakeMap[] column : matrix) {
|
||||
Arrays.fill(column, errorMap);
|
||||
public static @NotNull FakeMap[][][] getErrorMatrix(int width, int height) {
|
||||
FakeMap[] errorMaps = new FakeMap[] {getErrorInstance()};
|
||||
FakeMap[][][] matrix = new FakeMap[width][height][1];
|
||||
for (FakeMap[][] column : matrix) {
|
||||
Arrays.fill(column, errorMaps);
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
@@ -90,7 +93,7 @@ public class FakeMap extends FakeEntity {
|
||||
System.arraycopy(pixels, startX+(startY+y)*scanSize, this.pixels, y*DIMENSION, DIMENSION);
|
||||
}
|
||||
|
||||
plugin.fine("Created FakeMap#" + this.id);
|
||||
LOGGER.fine("Created FakeMap#" + this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +103,7 @@ public class FakeMap extends FakeEntity {
|
||||
public FakeMap(byte[] pixels) {
|
||||
this.id = getNextId();
|
||||
this.pixels = pixels;
|
||||
plugin.fine("Created FakeMap#" + this.id);
|
||||
LOGGER.fine("Created FakeMap#" + this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,36 +123,37 @@ public class FakeMap extends FakeEntity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send map pixels to player
|
||||
* @param player Player instance
|
||||
* Request re-send of map pixels
|
||||
* @param player Player who is expected to receive pixels
|
||||
* @return Whether re-send authorization was granted or not
|
||||
*/
|
||||
public void sendPixels(Player player) {
|
||||
public boolean requestResend(@NotNull Player player) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (now-lastPlayerSendTime.getOrDefault(uuid, 0L) <= RESEND_THRESHOLD) {
|
||||
return;
|
||||
|
||||
// Has enough time passed since last re-send?
|
||||
long last = lastPlayerSendTime.getOrDefault(uuid, 0L);
|
||||
if ((now-last) <= RESEND_THRESHOLD && (player.getLastPlayed()/1000) < last) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create map data packet
|
||||
PacketContainer mapDataPacket = new PacketContainer(PacketType.Play.Server.MAP);
|
||||
mapDataPacket.getModifier().writeDefaults();
|
||||
mapDataPacket.getIntegers()
|
||||
.write(0, id)
|
||||
.write(1, 0) // X
|
||||
.write(2, 0) // Z
|
||||
.write(3, DIMENSION) // Columns
|
||||
.write (4, DIMENSION); // Rows
|
||||
mapDataPacket.getBytes()
|
||||
.write(0, (byte) 0); // Scale (fully zoomed-in map)
|
||||
mapDataPacket.getBooleans()
|
||||
.write(0, false) // Tracking Position
|
||||
.write(1, true); // Locked
|
||||
mapDataPacket.getByteArrays()
|
||||
.write(0, pixels);
|
||||
|
||||
// Send packet
|
||||
tryToSendPacket(player, mapDataPacket);
|
||||
// Authorize re-send and update latest timestamp
|
||||
lastPlayerSendTime.put(uuid, now);
|
||||
plugin.fine("Sent pixels for FakeMap#" + id + " to Player#" + player.getName());
|
||||
LOGGER.fine("Granted sending pixels for FakeMap#" + id + " to Player#" + player.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map pixels packet
|
||||
* @return Map pixels packet
|
||||
*/
|
||||
public @NotNull MapDataPacket getPixelsPacket() {
|
||||
MapDataPacket mapDataPacket = new MapDataPacket();
|
||||
mapDataPacket.setId(id)
|
||||
.setScale(0) // Fully zoomed-in
|
||||
.setLocked(true)
|
||||
.setArea(DIMENSION, DIMENSION, 0, 0)
|
||||
.setPixels(pixels);
|
||||
return mapDataPacket;
|
||||
}
|
||||
}
|
||||
@@ -2,39 +2,67 @@ package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.utils.CsvConfiguration;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.event.vehicle.VehicleMoveEvent;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ImageRenderer implements Listener {
|
||||
static public final long SAVE_INTERVAL = 20L * 90; // In server ticks
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final String configPath;
|
||||
private static final long SAVE_INTERVAL = 20L * 90; // In server ticks
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageRenderer");
|
||||
private final Path configPath;
|
||||
private final boolean animateImages;
|
||||
private final int maxImageDimension;
|
||||
private BukkitTask saveTask;
|
||||
private final AtomicBoolean hasConfigChanged = new AtomicBoolean(false);
|
||||
private final ConcurrentMap<WorldAreaId, WorldArea> worldAreas = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, WorldAreaId> playersLocation = new HashMap<>();
|
||||
private final ConcurrentMap<WorldAreaId, Set<FakeImage>> images = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<UUID, Integer> imagesCountByPlayer = new ConcurrentHashMap<>();
|
||||
private final Map<Player, WorldAreaId> playersLocation = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param configPath Path to configuration file
|
||||
* @param configPath Path to configuration file
|
||||
* @param animateImages Whether to animate images or not
|
||||
* @param maxImageDimension Maximum image dimension in blocks
|
||||
*/
|
||||
public ImageRenderer(String configPath) {
|
||||
public ImageRenderer(@NotNull Path configPath, boolean animateImages, int maxImageDimension) {
|
||||
this.configPath = configPath;
|
||||
this.animateImages = animateImages;
|
||||
this.maxImageDimension = maxImageDimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is animation enabled
|
||||
* @return Is animation enabled
|
||||
*/
|
||||
public boolean isAnimationEnabled() {
|
||||
return animateImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum image dimension
|
||||
* @return Maximum image dimension in blocks
|
||||
*/
|
||||
public int getMaxImageDimension() {
|
||||
return maxImageDimension;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +70,7 @@ public class ImageRenderer implements Listener {
|
||||
*/
|
||||
public void start() {
|
||||
loadConfig();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
saveTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::saveConfig, SAVE_INTERVAL, SAVE_INTERVAL);
|
||||
}
|
||||
@@ -53,8 +82,10 @@ public class ImageRenderer implements Listener {
|
||||
HandlerList.unregisterAll(this);
|
||||
|
||||
// Destroy images from remote clients
|
||||
for (WorldArea worldArea : worldAreas.values()) {
|
||||
worldArea.unload();
|
||||
for (Set<FakeImage> fakeImagesPart : images.values()) {
|
||||
for (FakeImage fakeImage : fakeImagesPart) {
|
||||
fakeImage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Persist configuration
|
||||
@@ -62,14 +93,19 @@ public class ImageRenderer implements Listener {
|
||||
saveTask.cancel();
|
||||
}
|
||||
saveConfig();
|
||||
|
||||
// Clear dangling references
|
||||
images.clear();
|
||||
imagesCountByPlayer.clear();
|
||||
playersLocation.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from disk
|
||||
*/
|
||||
private void loadConfig() {
|
||||
if (!Files.isRegularFile(Paths.get(configPath))) {
|
||||
plugin.info("No placed fake images configuration file found");
|
||||
if (!Files.isRegularFile(configPath)) {
|
||||
LOGGER.info("No placed fake images configuration file found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +114,7 @@ public class ImageRenderer implements Listener {
|
||||
try {
|
||||
config.load(configPath);
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to load placed fake images from disk", e);
|
||||
LOGGER.severe("Failed to load placed fake images from disk", e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,24 +122,30 @@ public class ImageRenderer implements Listener {
|
||||
for (String[] row : config.getRows()) {
|
||||
try {
|
||||
String filename = row[0];
|
||||
World world = Objects.requireNonNull(plugin.getServer().getWorld(row[1]));
|
||||
World world = Objects.requireNonNull(YamipaPlugin.getInstance().getServer().getWorld(row[1]));
|
||||
double x = Integer.parseInt(row[2]);
|
||||
double y = Integer.parseInt(row[3]);
|
||||
double z = Integer.parseInt(row[4]);
|
||||
Location location = new Location(world, x, y, z);
|
||||
BlockFace face = BlockFace.valueOf(row[5]);
|
||||
Rotation rotation = Rotation.valueOf(row[6]);
|
||||
int width = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[7])));
|
||||
int height = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[8])));
|
||||
Date placedAt = (row.length > 9 && !row[9].equals("")) ?
|
||||
int width = Math.abs(Integer.parseInt(row[7]));
|
||||
int height = Math.abs(Integer.parseInt(row[8]));
|
||||
Date placedAt = (row.length > 9 && !row[9].isEmpty()) ?
|
||||
new Date(Long.parseLong(row[9])*1000L) :
|
||||
null;
|
||||
OfflinePlayer placedBy = (row.length > 10 && !row[10].equals("")) ?
|
||||
Bukkit.getOfflinePlayer(UUID.fromString(row[10])) :
|
||||
null;
|
||||
addImage(new FakeImage(filename, location, face, rotation, width, height, placedAt, placedBy), true);
|
||||
UUID placedById = (row.length > 10 && !row[10].isEmpty()) ?
|
||||
UUID.fromString(row[10]) :
|
||||
FakeImage.UNKNOWN_PLAYER_ID;
|
||||
OfflinePlayer placedBy = Bukkit.getOfflinePlayer(placedById);
|
||||
int flags = (row.length > 11) ?
|
||||
Math.max(Integer.parseInt(row[11]), 0) :
|
||||
FakeImage.DEFAULT_PLACE_FLAGS;
|
||||
FakeImage fakeImage = new FakeImage(filename, location, face, rotation, width, height,
|
||||
placedAt, placedBy, flags);
|
||||
addImage(fakeImage, true);
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Invalid fake image properties: " + String.join(";", row), e);
|
||||
LOGGER.severe("Invalid fake image properties: " + String.join(";", row), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,13 +153,14 @@ public class ImageRenderer implements Listener {
|
||||
/**
|
||||
* Save configuration to disk
|
||||
*/
|
||||
@SuppressWarnings("ExtractMethodRecommender")
|
||||
private void saveConfig() {
|
||||
if (!hasConfigChanged.get()) return;
|
||||
|
||||
// Get all fake images
|
||||
Set<FakeImage> fakeImages = new HashSet<>();
|
||||
for (WorldArea worldArea : worldAreas.values()) {
|
||||
fakeImages.addAll(worldArea.getImages());
|
||||
for (Set<FakeImage> fakeImagesPart : images.values()) {
|
||||
fakeImages.addAll(fakeImagesPart);
|
||||
}
|
||||
|
||||
// Placed here so, if another change comes while saving, we don't lose those changes (will be saved later)
|
||||
@@ -127,6 +170,7 @@ public class ImageRenderer implements Listener {
|
||||
CsvConfiguration config = new CsvConfiguration();
|
||||
for (FakeImage fakeImage : fakeImages) {
|
||||
Location location = fakeImage.getLocation();
|
||||
UUID placedById = fakeImage.getPlacedBy().getUniqueId();
|
||||
String[] row = new String[]{
|
||||
fakeImage.getFilename(),
|
||||
location.getChunk().getWorld().getName(),
|
||||
@@ -138,7 +182,8 @@ public class ImageRenderer implements Listener {
|
||||
fakeImage.getWidth() + "",
|
||||
fakeImage.getHeight() + "",
|
||||
(fakeImage.getPlacedAt() == null) ? "" : (fakeImage.getPlacedAt().getTime() / 1000) + "",
|
||||
(fakeImage.getPlacedBy() == null) ? "" : fakeImage.getPlacedBy().getUniqueId().toString()
|
||||
placedById.equals(FakeImage.UNKNOWN_PLAYER_ID) ? "" : placedById.toString(),
|
||||
fakeImage.getFlags() + ""
|
||||
};
|
||||
config.addRow(row);
|
||||
}
|
||||
@@ -146,35 +191,49 @@ public class ImageRenderer implements Listener {
|
||||
// Write to disk
|
||||
try {
|
||||
config.save(configPath);
|
||||
plugin.info("Saved placed fake images to disk");
|
||||
LOGGER.info("Saved placed fake images to disk");
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to save placed fake images to disk", e);
|
||||
LOGGER.severe("Failed to save placed fake images to disk", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image to renderer
|
||||
* @param fakeImage Fake image instance
|
||||
* @param isInit TRUE if called during renderer startup, FALSE otherwise
|
||||
* @param image Fake image instance
|
||||
* @param isInit TRUE if called during renderer startup, FALSE otherwise
|
||||
*/
|
||||
public void addImage(FakeImage fakeImage, boolean isInit) {
|
||||
WorldAreaId worldAreaId = fakeImage.getWorldAreaId();
|
||||
WorldArea worldArea = worldAreas.computeIfAbsent(worldAreaId, __ -> {
|
||||
plugin.fine("Created WorldArea#(" + worldAreaId + ")");
|
||||
return new WorldArea(worldAreaId);
|
||||
});
|
||||
worldArea.addImage(fakeImage);
|
||||
public void addImage(@NotNull FakeImage image, boolean isInit) {
|
||||
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
|
||||
|
||||
// Add image to renderer
|
||||
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
|
||||
images.computeIfAbsent(worldAreaId, __ -> {
|
||||
LOGGER.fine("Created WorldArea#(" + worldAreaId + ")");
|
||||
return ConcurrentHashMap.newKeySet();
|
||||
}).add(image);
|
||||
}
|
||||
|
||||
// Set configuration changed flag
|
||||
if (!isInit) {
|
||||
hasConfigChanged.set(true);
|
||||
}
|
||||
|
||||
// Increment count of placed images by player
|
||||
UUID placedById = image.getPlacedBy().getUniqueId();
|
||||
imagesCountByPlayer.compute(placedById, (__, prev) -> (prev == null) ? 1 : prev+1);
|
||||
|
||||
// Spawn image in players nearby
|
||||
for (Player player : getPlayersInViewDistance(imageWorldAreaIds)) {
|
||||
image.spawn(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image to renderer
|
||||
* @param fakeImage Fake image instance
|
||||
* @param image Fake image instance
|
||||
*/
|
||||
public void addImage(FakeImage fakeImage) {
|
||||
addImage(fakeImage, false);
|
||||
public void addImage(@NotNull FakeImage image) {
|
||||
addImage(image, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,15 +242,15 @@ public class ImageRenderer implements Listener {
|
||||
* @param face Fake image block face
|
||||
* @return Fake image instance or NULL if not found
|
||||
*/
|
||||
public FakeImage getImage(Location location, BlockFace face) {
|
||||
public @Nullable FakeImage getImage(@NotNull Location location, @NotNull BlockFace face) {
|
||||
WorldAreaId worldAreaId = WorldAreaId.fromLocation(location);
|
||||
WorldArea worldArea = worldAreas.get(worldAreaId);
|
||||
if (worldArea == null) {
|
||||
Set<FakeImage> candidateImages = images.get(worldAreaId);
|
||||
if (candidateImages == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find first fake image containing the given location
|
||||
for (FakeImage image : worldArea.getImages()) {
|
||||
for (FakeImage image : candidateImages) {
|
||||
if (image.contains(location, face)) {
|
||||
return image;
|
||||
}
|
||||
@@ -208,13 +267,13 @@ public class ImageRenderer implements Listener {
|
||||
* @param maxX Maximum X coordinate
|
||||
* @param minZ Minimum Z coordinate
|
||||
* @param maxZ Maximum Z coordinate
|
||||
* @return List of found images
|
||||
* @return Set of found images
|
||||
*/
|
||||
public Set<FakeImage> getImages(World world, int minX, int maxX, int minZ, int maxZ) {
|
||||
public @NotNull Set<FakeImage> getImages(@NotNull World world, int minX, int maxX, int minZ, int maxZ) {
|
||||
Set<FakeImage> response = new HashSet<>();
|
||||
for (WorldArea worldArea : worldAreas.values()) {
|
||||
if (!worldArea.getId().getWorld().getName().equals(world.getName())) continue;
|
||||
for (FakeImage image : worldArea.getImages()) {
|
||||
for (Map.Entry<WorldAreaId, Set<FakeImage>> entry : images.entrySet()) {
|
||||
if (!entry.getKey().getWorld().getName().equals(world.getName())) continue;
|
||||
for (FakeImage image : entry.getValue()) {
|
||||
Location loc = image.getLocation();
|
||||
if (loc.getBlockX() < minX || loc.getBlockX() > maxX) continue;
|
||||
if (loc.getBlockZ() < minZ || loc.getBlockZ() > maxZ) continue;
|
||||
@@ -228,31 +287,99 @@ public class ImageRenderer implements Listener {
|
||||
* Remove image from renderer
|
||||
* @param image Fake image instance
|
||||
*/
|
||||
public void removeImage(FakeImage image) {
|
||||
WorldAreaId worldAreaId = image.getWorldAreaId();
|
||||
WorldArea worldArea = worldAreas.get(worldAreaId);
|
||||
if (worldArea != null) {
|
||||
worldArea.removeImage(image);
|
||||
if (!worldArea.hasImages()) {
|
||||
plugin.fine("Destroyed WorldArea#(" + worldAreaId + ")");
|
||||
worldAreas.remove(worldAreaId);
|
||||
public void removeImage(@NotNull FakeImage image) {
|
||||
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
|
||||
|
||||
// Destroy image from all players nearby
|
||||
image.destroy();
|
||||
|
||||
// Remove image from renderer
|
||||
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
|
||||
Set<FakeImage> worldAreaImages = images.get(worldAreaId);
|
||||
worldAreaImages.remove(image);
|
||||
if (worldAreaImages.isEmpty()) {
|
||||
LOGGER.fine("Destroyed WorldArea#(" + worldAreaId + ")");
|
||||
images.remove(worldAreaId);
|
||||
}
|
||||
}
|
||||
|
||||
// Set configuration changed flag
|
||||
hasConfigChanged.set(true);
|
||||
|
||||
// Decrement count of placed images by player
|
||||
UUID placedById = image.getPlacedBy().getUniqueId();
|
||||
imagesCountByPlayer.compute(placedById, (__, prev) -> (prev != null && prev > 1) ? prev-1 : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available world areas from IDs
|
||||
* @param ids World area IDs
|
||||
* @return World area instances
|
||||
* Get set of players who have placed images
|
||||
* @return Offline players
|
||||
*/
|
||||
private Set<WorldArea> getWorldAreas(WorldAreaId[] ids) {
|
||||
Set<WorldArea> instances = new HashSet<>();
|
||||
for (WorldAreaId id : ids) {
|
||||
if (!worldAreas.containsKey(id)) continue;
|
||||
instances.add(worldAreas.get(id));
|
||||
public @NotNull Set<OfflinePlayer> getPlayersWithPlacedImages() {
|
||||
return imagesCountByPlayer.keySet().stream().map(Bukkit::getOfflinePlayer).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of placed images
|
||||
* @return Number of placed images
|
||||
*/
|
||||
public int size() {
|
||||
return imagesCountByPlayer.values().stream().reduce(0, Integer::sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of placed images grouped by player
|
||||
* <p>
|
||||
* NOTE: Response is sorted by image count (descending)
|
||||
* @return Images count by player
|
||||
*/
|
||||
public @NotNull Map<OfflinePlayer, Integer> getImagesCountByPlayer() {
|
||||
List<Map.Entry<UUID, Integer>> sortedEntries = new ArrayList<>(imagesCountByPlayer.entrySet());
|
||||
sortedEntries.sort(Map.Entry.comparingByValue(Comparator.reverseOrder()));
|
||||
|
||||
Map<OfflinePlayer, Integer> sortedMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<UUID, Integer> entry : sortedEntries) {
|
||||
sortedMap.put(Bukkit.getOfflinePlayer(entry.getKey()), entry.getValue());
|
||||
}
|
||||
return instances;
|
||||
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get players in view distance of the provided world area IDs
|
||||
* @param ids World area IDs
|
||||
* @return Players inside those world areas
|
||||
*/
|
||||
private @NotNull Set<Player> getPlayersInViewDistance(@NotNull WorldAreaId[] ids) {
|
||||
Set<WorldAreaId> neighborhood = new HashSet<>();
|
||||
for (WorldAreaId worldAreaId : ids) {
|
||||
Collections.addAll(neighborhood, worldAreaId.getNeighborhood());
|
||||
}
|
||||
|
||||
Set<Player> players = new HashSet<>();
|
||||
for (Map.Entry<Player, WorldAreaId> entry : playersLocation.entrySet()) {
|
||||
if (neighborhood.contains(entry.getValue())) {
|
||||
players.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get images in view distance from world area ID
|
||||
* @param worldAreaId World area ID
|
||||
* @return Set of fake images
|
||||
*/
|
||||
private @NotNull Set<FakeImage> getImagesInViewDistance(@NotNull WorldAreaId worldAreaId) {
|
||||
Set<FakeImage> response = new HashSet<>();
|
||||
for (WorldAreaId target : worldAreaId.getNeighborhood()) {
|
||||
Set<FakeImage> targetImages = images.get(target);
|
||||
if (targetImages != null) {
|
||||
response.addAll(targetImages);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,80 +387,91 @@ public class ImageRenderer implements Listener {
|
||||
* @param player Player instance
|
||||
* @param location New player location
|
||||
*/
|
||||
private void onPlayerLocationChange(Player player, Location location) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
private void onPlayerLocationChange(@NotNull Player player, @NotNull Location location) {
|
||||
// Ignore NPC events from other plugins
|
||||
if (player.hasMetadata("NPC")) {
|
||||
LOGGER.fine("Ignored NPC event from Player#" + player.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
// Has player moved to another world area?
|
||||
WorldAreaId worldAreaId = WorldAreaId.fromLocation(location);
|
||||
WorldAreaId prevWorldAreaId = playersLocation.get(uuid);
|
||||
WorldAreaId prevWorldAreaId = playersLocation.get(player);
|
||||
if (worldAreaId.equals(prevWorldAreaId)) {
|
||||
return;
|
||||
}
|
||||
playersLocation.put(uuid, worldAreaId);
|
||||
plugin.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
|
||||
playersLocation.put(player, worldAreaId);
|
||||
LOGGER.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
|
||||
|
||||
// Get world areas that should be loaded/unloaded
|
||||
Set<WorldArea> desiredState = getWorldAreas(worldAreaId.getNeighborhood());
|
||||
Set<WorldArea> currentState;
|
||||
if (prevWorldAreaId == null) {
|
||||
currentState = new HashSet<>();
|
||||
} else {
|
||||
currentState = getWorldAreas(prevWorldAreaId.getNeighborhood());
|
||||
}
|
||||
Set<WorldArea> areasToLoad = new HashSet<>(desiredState);
|
||||
areasToLoad.removeAll(currentState);
|
||||
Set<WorldArea> areasToUnload = new HashSet<>(currentState);
|
||||
areasToUnload.removeAll(desiredState);
|
||||
// Get images that should be spawned/destroyed
|
||||
Set<FakeImage> desiredState = getImagesInViewDistance(worldAreaId);
|
||||
Set<FakeImage> currentState = (prevWorldAreaId == null) ? new HashSet<>() : getImagesInViewDistance(prevWorldAreaId);
|
||||
Set<FakeImage> imagesToLoad = new HashSet<>(desiredState);
|
||||
imagesToLoad.removeAll(currentState);
|
||||
Set<FakeImage> imagesToUnload = new HashSet<>(currentState);
|
||||
imagesToUnload.removeAll(desiredState);
|
||||
|
||||
// Load/unload world areas
|
||||
for (WorldArea area : areasToUnload) {
|
||||
area.unload(player);
|
||||
plugin.fine("Unloaded WorldArea#(" + area.getId() + ") for Player#" + player.getName());
|
||||
// Spawn/destroy images
|
||||
for (FakeImage image : imagesToUnload) {
|
||||
image.destroy(player);
|
||||
}
|
||||
for (WorldArea area : areasToLoad) {
|
||||
area.load(player);
|
||||
plugin.fine("Loaded WorldArea#(" + area.getId() + ") for Player#" + player.getName());
|
||||
for (FakeImage image : imagesToLoad) {
|
||||
image.spawn(player);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerJoin(@NotNull PlayerJoinEvent event) {
|
||||
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
UUID uuid = player.getUniqueId();
|
||||
|
||||
// Get player's current world area ID
|
||||
WorldAreaId worldAreaId = playersLocation.get(uuid);
|
||||
WorldAreaId worldAreaId = playersLocation.get(player);
|
||||
if (worldAreaId == null) return;
|
||||
playersLocation.remove(uuid);
|
||||
playersLocation.remove(player);
|
||||
|
||||
// Notify world areas that player quit
|
||||
Set<WorldArea> loadedWorldAreas = getWorldAreas(worldAreaId.getNeighborhood());
|
||||
for (WorldArea worldArea : loadedWorldAreas) {
|
||||
worldArea.removePlayer(player);
|
||||
for (FakeImage image : getImagesInViewDistance(worldAreaId)) {
|
||||
image.notifyPlayerQuit(player);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerRespawn(PlayerRespawnEvent event) {
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerRespawn(@NotNull PlayerRespawnEvent event) {
|
||||
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerChangedWorld(PlayerChangedWorldEvent event) {
|
||||
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerMove(PlayerMoveEvent event) {
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerTeleport(@NotNull PlayerTeleportEvent event) {
|
||||
if (event.getTo() == null) return;
|
||||
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
|
||||
|
||||
// Player is about to change to another chunk, notify event
|
||||
// Wait until next server tick before handling location change
|
||||
// This is necessary as teleport events get fired *before* teleporting the player
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> onPlayerLocationChange(event.getPlayer(), event.getTo()));
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerMove(@NotNull PlayerMoveEvent event) {
|
||||
if (event.getTo() == null) return;
|
||||
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
|
||||
onPlayerLocationChange(event.getPlayer(), event.getTo());
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onVehicleMove(@NotNull VehicleMoveEvent event) {
|
||||
List<Entity> passengers = event.getVehicle().getPassengers();
|
||||
if (passengers.isEmpty()) return;
|
||||
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
|
||||
for (Entity passenger : passengers) {
|
||||
if (passenger instanceof Player) {
|
||||
onPlayerLocationChange((Player) passenger, event.getTo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.commands.ImageCommand;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.ActionBar;
|
||||
import io.josemmo.bukkit.plugin.utils.InteractWithEntityListener;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.hanging.HangingPlaceEvent;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ItemService extends InteractWithEntityListener implements Listener {
|
||||
private static final Logger LOGGER = Logger.getLogger("ItemService");
|
||||
private static final NamespacedKey NSK_FILENAME;
|
||||
private static final NamespacedKey NSK_WIDTH;
|
||||
private static final NamespacedKey NSK_HEIGHT;
|
||||
private static final NamespacedKey NSK_FLAGS;
|
||||
|
||||
static {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance(); // Only used for getting namespace, reference will be freed
|
||||
NSK_FILENAME = new NamespacedKey(plugin, "filename");
|
||||
NSK_WIDTH = new NamespacedKey(plugin, "width");
|
||||
NSK_HEIGHT = new NamespacedKey(plugin, "height");
|
||||
NSK_FLAGS = new NamespacedKey(plugin, "flags");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image item
|
||||
* @param image Image file
|
||||
* @param amount Stack amount
|
||||
* @param width Image width in blocks
|
||||
* @param height Image height in blocks
|
||||
* @param flags Image flags
|
||||
* @return Image item
|
||||
*/
|
||||
public static @NotNull ItemStack getImageItem(@NotNull ImageFile image, int amount, int width, int height, int flags) {
|
||||
ItemStack itemStack = new ItemStack(Material.ITEM_FRAME, amount);
|
||||
ItemMeta itemMeta = Objects.requireNonNull(itemStack.getItemMeta());
|
||||
|
||||
// Set metadata
|
||||
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
|
||||
itemMeta.setDisplayName(image.getFilename() + ChatColor.AQUA + " (" + width + "x" + height + ")");
|
||||
itemMeta.setLore(Collections.singletonList("Yamipa image"));
|
||||
itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getFilename());
|
||||
itemData.set(NSK_WIDTH, PersistentDataType.INTEGER, width);
|
||||
itemData.set(NSK_HEIGHT, PersistentDataType.INTEGER, height);
|
||||
itemData.set(NSK_FLAGS, PersistentDataType.INTEGER, flags);
|
||||
itemStack.setItemMeta(itemMeta);
|
||||
|
||||
return itemStack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start service
|
||||
*/
|
||||
public void start() {
|
||||
register();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop service
|
||||
*/
|
||||
public void stop() {
|
||||
unregister();
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onCraftItem(@NotNull PrepareItemCraftEvent event) {
|
||||
CraftingInventory inventory = event.getInventory();
|
||||
for (@Nullable ItemStack item : inventory.getMatrix()) {
|
||||
if (item == null) continue;
|
||||
|
||||
// Get metadata from item
|
||||
ItemMeta itemMeta = item.getItemMeta();
|
||||
if (itemMeta == null) continue;
|
||||
|
||||
// Prevent crafting recipes with image items
|
||||
if (itemMeta.getPersistentDataContainer().has(NSK_FILENAME, PersistentDataType.STRING)) {
|
||||
inventory.setResult(new ItemStack(Material.AIR));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST)
|
||||
public void onPlaceItem(@NotNull HangingPlaceEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (player == null) return;
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
|
||||
// Prepare variables to hold item metadata
|
||||
boolean isOffHand = false;
|
||||
ItemStack item = null;
|
||||
String filename = null;
|
||||
Integer width = null;
|
||||
Integer height = null;
|
||||
Integer flags = null;
|
||||
|
||||
// Find placed item stack in player's inventory
|
||||
// NOTE: we cannot use `event.getItemStack()` as it's only supported since 1.17.1
|
||||
for (int i=0; i<2; i++) {
|
||||
isOffHand = (i == 1);
|
||||
item = isOffHand ? inventory.getItemInOffHand() : inventory.getItemInMainHand();
|
||||
|
||||
// Get item metadata
|
||||
ItemMeta itemMeta = item.getItemMeta();
|
||||
if (itemMeta == null) continue;
|
||||
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
|
||||
filename = itemData.get(NSK_FILENAME, PersistentDataType.STRING);
|
||||
if (filename == null) continue;
|
||||
|
||||
// Found item, parse metadata
|
||||
width = itemData.get(NSK_WIDTH, PersistentDataType.INTEGER);
|
||||
height = itemData.get(NSK_HEIGHT, PersistentDataType.INTEGER);
|
||||
flags = itemData.get(NSK_FLAGS, PersistentDataType.INTEGER);
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate item and metadata
|
||||
if (filename == null) {
|
||||
return;
|
||||
}
|
||||
if (width == null || height == null || flags == null) {
|
||||
LOGGER.warning(player + " tried to place corrupted image item (missing width/height/flags properties)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
ImageFile image = YamipaPlugin.getInstance().getStorage().get(filename);
|
||||
if (image == null) {
|
||||
LOGGER.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)");
|
||||
ActionBar.send(player, ChatColor.RED + "Image file \"" + filename + "\" no longer exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent item frame placing
|
||||
event.setCancelled(true);
|
||||
|
||||
// Validate player permissions
|
||||
if (!player.hasPermission("yamipa.item.place")) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to place image items!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to place image in world
|
||||
Location location = event.getBlock().getLocation();
|
||||
boolean success = ImageCommand.placeImage(player, image, width, height, flags, location, event.getBlockFace());
|
||||
if (!success) return;
|
||||
|
||||
// Decrement item from player's inventory
|
||||
if (player.getGameMode() != GameMode.CREATIVE) {
|
||||
int amount = item.getAmount();
|
||||
if (amount > 1) {
|
||||
item.setAmount(amount - 1);
|
||||
} else {
|
||||
item = new ItemStack(Material.AIR);
|
||||
}
|
||||
if (isOffHand) {
|
||||
inventory.setItemInOffHand(item);
|
||||
} else {
|
||||
inventory.setItemInMainHand(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
Location location = block.getLocation();
|
||||
|
||||
// Has the player clicked a removable placed image?
|
||||
FakeImage image = renderer.getImage(location, face);
|
||||
if (image == null || !image.hasFlag(FakeImage.FLAG_REMOVABLE)) return true;
|
||||
|
||||
// Validate player permissions
|
||||
if (!player.hasPermission("yamipa.item.remove.own")) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to remove image items!");
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!player.getUniqueId().equals(image.getPlacedBy().getUniqueId()) &&
|
||||
!player.hasPermission("yamipa.item.remove")
|
||||
) {
|
||||
ActionBar.send(player, ChatColor.RED + "You cannot remove image items from other players!");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Attempt to remove image
|
||||
boolean success = ImageCommand.removeImage(player, image);
|
||||
if (!success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drop image item
|
||||
if (player.getGameMode() == GameMode.SURVIVAL && image.hasFlag(FakeImage.FLAG_DROPPABLE)) {
|
||||
ImageFile imageFile = Objects.requireNonNull(image.getFile());
|
||||
ItemStack imageItem = getImageItem(imageFile, 1, image.getWidth(), image.getHeight(), image.getFlags());
|
||||
Location dropLocation = location.clone().add(0.5, -0.5, 0.5).add(face.getDirection());
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> block.getWorld().dropItem(dropLocation, imageItem));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
|
||||
// Intentionally left blank
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* A World Area is a group of 16 chunks arranged in a 4x4 square.
|
||||
*/
|
||||
public class WorldArea {
|
||||
private final WorldAreaId id;
|
||||
private final Set<FakeImage> fakeImages = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Player> players = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param id World area ID
|
||||
*/
|
||||
public WorldArea(WorldAreaId id) {
|
||||
this.id = id;
|
||||
|
||||
// Initialize current list of players in this area
|
||||
for (Player player : id.getWorld().getPlayers()) {
|
||||
WorldAreaId playerWorldAreaId = WorldAreaId.fromLocation(player.getLocation());
|
||||
if (id.equals(playerWorldAreaId)) {
|
||||
players.add(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get world area ID
|
||||
* @return World area ID
|
||||
*/
|
||||
public WorldAreaId getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains fake images
|
||||
* @return TRUE if instance contains fake images, FALSE otherwise
|
||||
*/
|
||||
public boolean hasImages() {
|
||||
return !fakeImages.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fake images
|
||||
* @return Set of fake images
|
||||
*/
|
||||
public Set<FakeImage> getImages() {
|
||||
return fakeImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add fake image
|
||||
* @param image Fake image instance
|
||||
*/
|
||||
public void addImage(FakeImage image) {
|
||||
fakeImages.add(image);
|
||||
for (Player player : players) {
|
||||
image.spawn(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove fake image
|
||||
* @param image Fake image instance
|
||||
*/
|
||||
public void removeImage(FakeImage image) {
|
||||
fakeImages.remove(image);
|
||||
for (Player player : players) {
|
||||
image.destroy(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load for player
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void load(Player player) {
|
||||
players.add(player);
|
||||
for (FakeImage image : fakeImages) {
|
||||
image.spawn(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove player from world area
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void removePlayer(Player player) {
|
||||
players.remove(player);
|
||||
if (players.isEmpty()) {
|
||||
for (FakeImage image : fakeImages) {
|
||||
image.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload for player
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void unload(Player player) {
|
||||
for (FakeImage image : fakeImages) {
|
||||
image.destroy(player);
|
||||
}
|
||||
removePlayer(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload for all players
|
||||
*/
|
||||
public void unload() {
|
||||
for (Player player : new ArrayList<>(players)) {
|
||||
unload(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,40 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* World Area IDs represent groups of 16 chunks arranged in a 4x4 square.
|
||||
*/
|
||||
public class WorldAreaId {
|
||||
private static boolean USE_WORLD_VIEW_DISTANCE = true;
|
||||
private static final Map<String, Integer> SIZES_PER_WORLD = new HashMap<>();
|
||||
private final World world;
|
||||
private final int x;
|
||||
private final int z;
|
||||
private WorldAreaId[] neighborhood = null;
|
||||
|
||||
static {
|
||||
try {
|
||||
World.class.getMethod("getViewDistance");
|
||||
} catch (Exception e) {
|
||||
USE_WORLD_VIEW_DISTANCE = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ID from location
|
||||
* @param location Location instance
|
||||
* @return World area ID
|
||||
*/
|
||||
public static WorldAreaId fromLocation(Location location) {
|
||||
public static @NotNull WorldAreaId fromLocation(@NotNull Location location) {
|
||||
Chunk chunk = location.getChunk();
|
||||
return new WorldAreaId(chunk.getWorld(), chunk.getX() >> 2, chunk.getZ() >> 2);
|
||||
}
|
||||
@@ -26,7 +45,7 @@ public class WorldAreaId {
|
||||
* @param x World area X coordinate
|
||||
* @param z World area Z coordinate
|
||||
*/
|
||||
public WorldAreaId(World world, int x, int z) {
|
||||
public WorldAreaId(@NotNull World world, int x, int z) {
|
||||
this.world = world;
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
@@ -36,30 +55,349 @@ public class WorldAreaId {
|
||||
* Get world instance
|
||||
* @return World instance
|
||||
*/
|
||||
public World getWorld() {
|
||||
public @NotNull World getWorld() {
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get colliding world area IDs plus this one
|
||||
* @return List of neighbors
|
||||
* Get nearby world area IDs in view distance (plus this one)
|
||||
* @return Array of neighbors
|
||||
*/
|
||||
public WorldAreaId[] getNeighborhood() {
|
||||
return new WorldAreaId[]{
|
||||
public @NotNull WorldAreaId[] getNeighborhood() {
|
||||
// Get value from cache (if available)
|
||||
if (neighborhood != null) {
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Calculate neighborhood size from world's view distance
|
||||
// NOTE: World size is cached to prevent issues with plugins that modify it at runtime
|
||||
int size = SIZES_PER_WORLD.computeIfAbsent(world.getName(), (__) -> {
|
||||
int distance = USE_WORLD_VIEW_DISTANCE ? world.getViewDistance() : Bukkit.getServer().getViewDistance();
|
||||
return distance / 4;
|
||||
});
|
||||
|
||||
// Size 0 (1+1+1=3)
|
||||
// ·|·
|
||||
// XOX
|
||||
// ·|·
|
||||
if (size == 0) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x, z-1),
|
||||
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
|
||||
new WorldAreaId(world, x, z+1),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 1 (2+1+2=5)
|
||||
// ·X|X·
|
||||
// XX|XX
|
||||
// XXOXX
|
||||
// XX|XX
|
||||
// ·X|X·
|
||||
if (size == 1) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 2 (3+1+3=7)
|
||||
// ··X|X··
|
||||
// ·XX|XX·
|
||||
// XXX|XXX
|
||||
// XXXOXXX
|
||||
// XXX|XXX
|
||||
// ·XX|XX·
|
||||
// ··X|X··
|
||||
if (size == 2) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 3 (4+1+4=9)
|
||||
// ···X|X···
|
||||
// ·XXX|XXX·
|
||||
// ·XXX|XXX·
|
||||
// XXXX|XXXX
|
||||
// XXXXOXXXX
|
||||
// XXXX|XXXX
|
||||
// ·XXX|XXX·
|
||||
// ·XXX|XXX·
|
||||
// ···X|X···
|
||||
if (size == 3) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-4),
|
||||
new WorldAreaId(world, x, z-4),
|
||||
new WorldAreaId(world, x+1, z-4),
|
||||
|
||||
new WorldAreaId(world, x-3, z-3),
|
||||
new WorldAreaId(world, x-2, z-3),
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
new WorldAreaId(world, x+2, z-3),
|
||||
new WorldAreaId(world, x+3, z-3),
|
||||
|
||||
new WorldAreaId(world, x-3, z-2),
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
new WorldAreaId(world, x+3, z-2),
|
||||
|
||||
new WorldAreaId(world, x-4, z-1),
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
new WorldAreaId(world, x+4, z-1),
|
||||
|
||||
new WorldAreaId(world, x-4, z),
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
new WorldAreaId(world, x+4, z),
|
||||
|
||||
new WorldAreaId(world, x-4, z+1),
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
new WorldAreaId(world, x+4, z+1),
|
||||
|
||||
new WorldAreaId(world, x-3, z+2),
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
new WorldAreaId(world, x+3, z+2),
|
||||
|
||||
new WorldAreaId(world, x-3, z+3),
|
||||
new WorldAreaId(world, x-2, z+3),
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
new WorldAreaId(world, x+2, z+3),
|
||||
new WorldAreaId(world, x+3, z+3),
|
||||
|
||||
new WorldAreaId(world, x-1, z+4),
|
||||
new WorldAreaId(world, x, z+4),
|
||||
new WorldAreaId(world, x+1, z+4),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size ≥4 (5+1+5=11)
|
||||
// ···XX|XX···
|
||||
// ··XXX|XXX··
|
||||
// ·XXXX|XXXX·
|
||||
// XXXXX|XXXXX
|
||||
// XXXXX|XXXXX
|
||||
// XXXXXOXXXXX
|
||||
// XXXXX|XXXXX
|
||||
// XXXXX|XXXXX
|
||||
// ·XXXX|XXXX·
|
||||
// ··XXX|XXX··
|
||||
// ···XX|XX···
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-2, z-5),
|
||||
new WorldAreaId(world, x-1, z-5),
|
||||
new WorldAreaId(world, x, z-5),
|
||||
new WorldAreaId(world, x+1, z-5),
|
||||
new WorldAreaId(world, x+2, z-5),
|
||||
|
||||
new WorldAreaId(world, x-3, z-4),
|
||||
new WorldAreaId(world, x-2, z-4),
|
||||
new WorldAreaId(world, x-1, z-4),
|
||||
new WorldAreaId(world, x, z-4),
|
||||
new WorldAreaId(world, x+1, z-4),
|
||||
new WorldAreaId(world, x+2, z-4),
|
||||
new WorldAreaId(world, x+3, z-4),
|
||||
|
||||
new WorldAreaId(world, x-4, z-3),
|
||||
new WorldAreaId(world, x-3, z-3),
|
||||
new WorldAreaId(world, x-2, z-3),
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
new WorldAreaId(world, x+2, z-3),
|
||||
new WorldAreaId(world, x+3, z-3),
|
||||
new WorldAreaId(world, x+4, z-3),
|
||||
|
||||
new WorldAreaId(world, x-5, z-2),
|
||||
new WorldAreaId(world, x-4, z-2),
|
||||
new WorldAreaId(world, x-3, z-2),
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
new WorldAreaId(world, x+3, z-2),
|
||||
new WorldAreaId(world, x+4, z-2),
|
||||
new WorldAreaId(world, x+5, z-2),
|
||||
|
||||
new WorldAreaId(world, x-5, z-1),
|
||||
new WorldAreaId(world, x-4, z-1),
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
new WorldAreaId(world, x+4, z-1),
|
||||
new WorldAreaId(world, x+5, z-1),
|
||||
|
||||
new WorldAreaId(world, x-5, z),
|
||||
new WorldAreaId(world, x-4, z),
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
new WorldAreaId(world, x+4, z),
|
||||
new WorldAreaId(world, x+5, z),
|
||||
|
||||
new WorldAreaId(world, x-5, z+1),
|
||||
new WorldAreaId(world, x-4, z+1),
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1)
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
new WorldAreaId(world, x+4, z+1),
|
||||
new WorldAreaId(world, x+5, z+1),
|
||||
|
||||
new WorldAreaId(world, x-5, z+2),
|
||||
new WorldAreaId(world, x-4, z+2),
|
||||
new WorldAreaId(world, x-3, z+2),
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
new WorldAreaId(world, x+3, z+2),
|
||||
new WorldAreaId(world, x+4, z+2),
|
||||
new WorldAreaId(world, x+5, z+2),
|
||||
|
||||
new WorldAreaId(world, x-4, z+3),
|
||||
new WorldAreaId(world, x-3, z+3),
|
||||
new WorldAreaId(world, x-2, z+3),
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
new WorldAreaId(world, x+2, z+3),
|
||||
new WorldAreaId(world, x+3, z+3),
|
||||
new WorldAreaId(world, x+4, z+3),
|
||||
|
||||
new WorldAreaId(world, x-3, z+4),
|
||||
new WorldAreaId(world, x-2, z+4),
|
||||
new WorldAreaId(world, x-1, z+4),
|
||||
new WorldAreaId(world, x, z+4),
|
||||
new WorldAreaId(world, x+1, z+4),
|
||||
new WorldAreaId(world, x+2, z+4),
|
||||
new WorldAreaId(world, x+3, z+4),
|
||||
|
||||
new WorldAreaId(world, x-2, z+5),
|
||||
new WorldAreaId(world, x-1, z+5),
|
||||
new WorldAreaId(world, x, z+5),
|
||||
new WorldAreaId(world, x+1, z+5),
|
||||
new WorldAreaId(world, x+2, z+5),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
WorldAreaId that = (WorldAreaId) o;
|
||||
@@ -72,7 +410,7 @@ public class WorldAreaId {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
public @NotNull String toString() {
|
||||
return world.getName() + "," + x + "," + z;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeMap;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.metadata.IIOMetadataNode;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class CachedMapsFile extends SynchronizedFile {
|
||||
private static final String CACHE_EXT = "cache";
|
||||
private static final byte[] CACHE_SIGNATURE = new byte[] {0x59, 0x4d, 0x50}; // "YMP"
|
||||
private static final int CACHE_VERSION = 1;
|
||||
private static final Logger LOGGER = Logger.getLogger("CachedMapsFile");
|
||||
private final ImageFile imageFile;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private FakeMap[][][] maps;
|
||||
private int delay;
|
||||
|
||||
/**
|
||||
* Create instance from image file
|
||||
* @param imageFile Image file instance
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Cached maps instance
|
||||
*/
|
||||
public static @NotNull CachedMapsFile from(@NotNull ImageFile imageFile, int width, int height) {
|
||||
Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath();
|
||||
Path path = cachePath.resolve(imageFile.getFilename() + "." + width + "-" + height + "." + CACHE_EXT);
|
||||
return new CachedMapsFile(path, imageFile, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all cached maps files associated to an image file
|
||||
* @param imageFile Image file instance
|
||||
*/
|
||||
public static void deleteAll(@NotNull ImageFile imageFile) {
|
||||
String relativeFilename = imageFile.getFilename();
|
||||
Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath();
|
||||
File baseDirectory = cachePath.resolve(relativeFilename).getParent().toFile();
|
||||
String cachePattern = Pattern.quote(Paths.get(relativeFilename).getFileName().toString()) +
|
||||
"\\.[0-9]+-[0-9]+\\." + CACHE_EXT;
|
||||
|
||||
// Find cache files to delete
|
||||
if (!baseDirectory.exists()) {
|
||||
// Cache subdirectory does not exist, no need to delete files
|
||||
return;
|
||||
}
|
||||
File[] files = baseDirectory.listFiles((__, item) -> item.matches(cachePattern));
|
||||
if (files == null) {
|
||||
LOGGER.warning("An error occurred when listing cache files for image \"" + relativeFilename + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete disk cache files
|
||||
for (File file : files) {
|
||||
if (!file.delete()) {
|
||||
LOGGER.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to cached maps file in disk
|
||||
* @param imageFile Image file associated to these maps
|
||||
* @param width Width in blocks
|
||||
* @param height Height blocks
|
||||
*/
|
||||
private CachedMapsFile(@NotNull Path path, @NotNull ImageFile imageFile, int width, int height) {
|
||||
super(path);
|
||||
this.imageFile = imageFile;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maps
|
||||
* @return Tri-dimensional array of maps (column, row, step)
|
||||
*/
|
||||
public @NotNull FakeMap[][][] getMaps() {
|
||||
return maps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delay between steps
|
||||
* @return Delay in 50ms intervals or <code>0</code> if not applicable
|
||||
*/
|
||||
public int getDelay() {
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load maps
|
||||
*/
|
||||
private void load() {
|
||||
// Try to load maps from disk
|
||||
if (exists() && getLastModified() > imageFile.getLastModified()) {
|
||||
LOGGER.fine("Found warm cache file \"" + path + "\"");
|
||||
try {
|
||||
loadFromDisk();
|
||||
return;
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOGGER.info("Cache file \"" + path + "\" is outdated and will be overwritten");
|
||||
} catch (Exception e) {
|
||||
LOGGER.warning("Cache file \"" + path + "\" is corrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate maps from image file
|
||||
LOGGER.fine("Missed cache file \"" + path + "\"");
|
||||
try {
|
||||
generateFromImage();
|
||||
tryToWriteToDisk();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to render image step(s) from file \"" + path + "\"", e);
|
||||
}
|
||||
|
||||
// Fallback to error matrix
|
||||
maps = FakeMap.getErrorMatrix(width, height);
|
||||
delay = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data from disk
|
||||
* @throws IllegalArgumentException if cache file is outdated
|
||||
* @throws IOException if cache file is corrupted
|
||||
*/
|
||||
private void loadFromDisk() throws IllegalArgumentException, IOException {
|
||||
try (RandomAccessFile stream = read()) {
|
||||
// Validate file signature
|
||||
for (byte expectedByte : CACHE_SIGNATURE) {
|
||||
if ((byte) stream.read() != expectedByte) {
|
||||
throw new IllegalArgumentException("Invalid file signature");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate version number
|
||||
if ((byte) stream.read() != CACHE_VERSION) {
|
||||
throw new IllegalArgumentException("Incompatible file format version");
|
||||
}
|
||||
|
||||
// Get number of animation steps
|
||||
int numOfSteps = stream.read() | (stream.read() << 8);
|
||||
if (numOfSteps < 1 || numOfSteps > FakeImage.MAX_STEPS) {
|
||||
throw new IOException("Invalid number of animation steps: " + numOfSteps);
|
||||
}
|
||||
|
||||
// Get delay between steps
|
||||
int delay = 0;
|
||||
if (numOfSteps > 1) {
|
||||
delay = stream.read();
|
||||
if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) {
|
||||
throw new IOException("Invalid delay between steps: " + delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Read pixels
|
||||
FakeMap[][][] maps = new FakeMap[width][height][numOfSteps];
|
||||
for (int col=0; col<width; ++col) {
|
||||
for (int row=0; row<height; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
byte[] buffer = new byte[FakeMap.DIMENSION*FakeMap.DIMENSION];
|
||||
stream.read(buffer);
|
||||
maps[col][row][step] = new FakeMap(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update instance state
|
||||
this.maps = maps;
|
||||
this.delay = delay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate data from image
|
||||
* @throws IOException if an I/O error occurred
|
||||
* @throws RuntimeException if failed to render image steps
|
||||
*/
|
||||
private void generateFromImage() throws IOException, RuntimeException {
|
||||
int widthInPixels = width * FakeMap.DIMENSION;
|
||||
int heightInPixels = height * FakeMap.DIMENSION;
|
||||
|
||||
// Render image steps in Minecraft color palette
|
||||
List<byte[]> renderedImages = new ArrayList<>();
|
||||
Map<Integer, Integer> delays = new HashMap<>();
|
||||
try (ImageInputStream inputStream = ImageIO.createImageInputStream(imageFile.read())) {
|
||||
ImageReader reader = ImageIO.getImageReaders(inputStream).next();
|
||||
reader.setInput(inputStream);
|
||||
String format = reader.getFormatName().toLowerCase();
|
||||
|
||||
// Create temporary canvas
|
||||
int originalWidth = reader.getWidth(0);
|
||||
int originalHeight = reader.getHeight(0);
|
||||
BufferedImage tmpImage = new BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_4BYTE_ABGR);
|
||||
Graphics2D tmpGraphics = tmpImage.createGraphics();
|
||||
tmpGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Create temporary scaled canvas (for resizing)
|
||||
BufferedImage tmpScaledImage = new BufferedImage(widthInPixels, heightInPixels, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D tmpScaledGraphics = tmpScaledImage.createGraphics();
|
||||
tmpScaledGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Read images from file
|
||||
for (int step=0; step<FakeImage.MAX_STEPS; ++step) {
|
||||
try {
|
||||
// Extract step metadata
|
||||
int imageLeft = 0;
|
||||
int imageTop = 0;
|
||||
boolean disposePrevious = false;
|
||||
if (format.equals("gif")) {
|
||||
IIOMetadata metadata = reader.getImageMetadata(step);
|
||||
String formatName = metadata.getNativeMetadataFormatName();
|
||||
IIOMetadataNode metadataRoot = (IIOMetadataNode) metadata.getAsTree(formatName);
|
||||
for (int i=0; i<metadataRoot.getLength(); ++i) {
|
||||
String nodeName = metadataRoot.item(i).getNodeName();
|
||||
if (nodeName.equalsIgnoreCase("ImageDescriptor")) {
|
||||
IIOMetadataNode descriptorNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
imageLeft = Integer.parseInt(descriptorNode.getAttribute("imageLeftPosition"));
|
||||
imageTop = Integer.parseInt(descriptorNode.getAttribute("imageTopPosition"));
|
||||
} else if (nodeName.equalsIgnoreCase("GraphicControlExtension")) {
|
||||
IIOMetadataNode controlExtensionNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
int delay = Integer.parseInt(controlExtensionNode.getAttribute("delayTime"));
|
||||
delays.compute(delay, (__, count) -> (count == null) ? 1 : count + 1);
|
||||
disposePrevious = controlExtensionNode.getAttribute("disposalMethod").startsWith("restore");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear temporary canvases (if needed)
|
||||
if (disposePrevious) {
|
||||
tmpGraphics.clearRect(0, 0, originalWidth, originalHeight);
|
||||
tmpScaledGraphics.clearRect(0, 0, widthInPixels, heightInPixels);
|
||||
}
|
||||
|
||||
// Paint step image over temporary canvas
|
||||
BufferedImage image = reader.read(step);
|
||||
tmpGraphics.drawImage(image, imageLeft, imageTop, null);
|
||||
image.flush();
|
||||
|
||||
// Resize image and get pixels
|
||||
tmpScaledGraphics.drawImage(tmpImage, 0, 0, widthInPixels, heightInPixels, null);
|
||||
int[] rgbaPixels = ((DataBufferInt) tmpScaledImage.getRaster().getDataBuffer()).getData();
|
||||
|
||||
// Convert RGBA pixels to Minecraft color indexes
|
||||
byte[] renderedImage = new byte[widthInPixels * heightInPixels];
|
||||
IntStream.range(0, rgbaPixels.length)
|
||||
.parallel()
|
||||
.forEach(pixelIndex -> renderedImage[pixelIndex] = FakeMap.pixelToIndex(rgbaPixels[pixelIndex]));
|
||||
renderedImages.add(renderedImage);
|
||||
} catch (IndexOutOfBoundsException __) {
|
||||
// No more steps to read
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Free resources
|
||||
reader.dispose();
|
||||
tmpGraphics.dispose();
|
||||
tmpImage.flush();
|
||||
tmpScaledGraphics.dispose();
|
||||
tmpScaledImage.flush();
|
||||
}
|
||||
|
||||
// Get most occurring delay (mode)
|
||||
int delay = 0;
|
||||
if (renderedImages.size() > 1) {
|
||||
delay = Collections.max(delays.entrySet(), Map.Entry.comparingByValue()).getKey();
|
||||
delay = Math.round(delay * 0.2f); // (delay * 10) / 50
|
||||
delay = Math.min(Math.max(delay, FakeImage.MIN_DELAY), FakeImage.MAX_DELAY);
|
||||
}
|
||||
|
||||
// Instantiate fake maps from image steps
|
||||
FakeMap[][][] maps = new FakeMap[width][height][renderedImages.size()];
|
||||
IntStream.range(0, renderedImages.size()).forEach(i -> {
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
maps[col][row][i] = new FakeMap(
|
||||
renderedImages.get(i),
|
||||
widthInPixels,
|
||||
col*FakeMap.DIMENSION,
|
||||
row*FakeMap.DIMENSION
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update instance state
|
||||
this.maps = maps;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to write data to disk
|
||||
*/
|
||||
private void tryToWriteToDisk() {
|
||||
mkdirs();
|
||||
try (RandomAccessFile stream = write()) {
|
||||
int numOfSteps = maps[0][0].length;
|
||||
|
||||
// Add file header
|
||||
stream.write(CACHE_SIGNATURE); // "YMP" signature
|
||||
stream.write(CACHE_VERSION); // Format version
|
||||
stream.write(numOfSteps & 0xff); // Number of animation steps (first byte)
|
||||
stream.write((numOfSteps >> 8) & 0xff); // Number of animation steps (second byte)
|
||||
if (numOfSteps > 1) {
|
||||
stream.write(delay);
|
||||
}
|
||||
|
||||
// Add pixels
|
||||
//noinspection ForLoopReplaceableByForEach
|
||||
for (int col=0; col<maps.length; ++col) {
|
||||
for (int row=0; row<maps[0].length; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
stream.write(maps[col][row][step].getPixels());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to write to cache file \"" + path + "\"", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import com.sun.nio.file.ExtendedWatchEventModifier;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service for detecting file events inside a given directory.
|
||||
* <p>
|
||||
* It supports recursive storage (<i>e.g.,</i> nested directories) and watches for file system changes in realtime
|
||||
* when supported by the OS.
|
||||
*/
|
||||
public abstract class FileSystemWatcher {
|
||||
private static final int MAX_DEPTH = 32;
|
||||
private static final int POLLING_INTERVAL = 4000;
|
||||
private static final String PROBE_FILENAME = ".inotify_test";
|
||||
private static final Logger LOGGER = Logger.getLogger("FileSystemWatcher");
|
||||
protected final Path basePath;
|
||||
/** Map of existing directories with the files they contain and their last modification timestamps */
|
||||
private final SortedMap<Path, Map<Path, Long>> fileTree = new TreeMap<>();
|
||||
private @Nullable Thread watcherThread;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param basePath Base path
|
||||
*/
|
||||
public FileSystemWatcher(@NotNull Path basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watcher
|
||||
* @throws RuntimeException if failed to start
|
||||
*/
|
||||
protected void start() throws RuntimeException {
|
||||
// Prevent initializing more than once
|
||||
if (watcherThread != null) {
|
||||
throw new RuntimeException("File system watcher is already running");
|
||||
}
|
||||
|
||||
// Perform initial scan
|
||||
scan();
|
||||
|
||||
// Start watching files
|
||||
watcherThread = new WatcherThread();
|
||||
watcherThread.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watcher
|
||||
*/
|
||||
protected void stop() {
|
||||
if (watcherThread != null) {
|
||||
watcherThread.interrupt();
|
||||
watcherThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file created
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileCreated(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* On file modified
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileModified(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* On file deleted
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileDeleted(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* Scan base directory recursively
|
||||
*/
|
||||
private void scan() {
|
||||
synchronized (fileTree) {
|
||||
// Assume all directories and files have been deleted, will discard existing items afterward
|
||||
Set<Path> deletedDirectories = new HashSet<>(fileTree.keySet());
|
||||
Set<Path> deletedFiles = fileTree.values().stream()
|
||||
.map(Map::keySet)
|
||||
.flatMap(Set::stream)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Traverse file tree
|
||||
Set<FileVisitOption> options = EnumSet.noneOf(FileVisitOption.class);
|
||||
try {
|
||||
Files.walkFileTree(basePath, options, MAX_DEPTH, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) {
|
||||
fileTree.putIfAbsent(path, new HashMap<>());
|
||||
deletedDirectories.remove(path);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
|
||||
Map<Path, Long> subtree = fileTree.get(path.getParent());
|
||||
Long oldModifiedAt = subtree.get(path);
|
||||
long newModifiedAt = attrs.lastModifiedTime().toMillis();
|
||||
if (oldModifiedAt == null) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileCreated(path);
|
||||
} else if (newModifiedAt > oldModifiedAt) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileModified(path);
|
||||
}
|
||||
deletedFiles.remove(path);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to list files in directory", e);
|
||||
}
|
||||
|
||||
// Process deleted files and directories
|
||||
for (Path path : deletedFiles) {
|
||||
fileTree.get(path.getParent()).remove(path);
|
||||
onFileDeleted(path);
|
||||
}
|
||||
for (Path path : deletedDirectories) {
|
||||
fileTree.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class WatcherThread extends Thread {
|
||||
private final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (isInotifySupported()) {
|
||||
runWithFileSystemEvents();
|
||||
} else {
|
||||
LOGGER.warning("Device does not support inotify, detection of file changes will be slower");
|
||||
runWithPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is inotify supported
|
||||
* @return Whether inotify is supported
|
||||
*/
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
private boolean isInotifySupported() {
|
||||
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
|
||||
// Start listening for events
|
||||
basePath.register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
);
|
||||
|
||||
// Create and delete probe directory
|
||||
File testFile = basePath.resolve(PROBE_FILENAME).toFile();
|
||||
testFile.mkdir();
|
||||
testFile.delete();
|
||||
|
||||
// Check that at least one event was emitted
|
||||
WatchKey watchKey = watchService.poll();
|
||||
return (watchKey != null && !watchKey.pollEvents().isEmpty());
|
||||
} catch (IOException __) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run with polling
|
||||
*/
|
||||
@SuppressWarnings({"InfiniteLoopStatement", "BusyWait"})
|
||||
private void runWithPolling() {
|
||||
try {
|
||||
while (true) {
|
||||
Thread.sleep(POLLING_INTERVAL);
|
||||
scan();
|
||||
}
|
||||
} catch (InterruptedException __) {
|
||||
// Silently ignore exception, this is expected when service shuts down
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run with file system events
|
||||
*/
|
||||
private void runWithFileSystemEvents() {
|
||||
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
|
||||
// Register initial directories
|
||||
synchronized (fileTree) {
|
||||
for (Path path : fileTree.keySet()) {
|
||||
registerDirectory(watchService, path);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for events
|
||||
WatchKey key;
|
||||
try {
|
||||
while ((key = watchService.take()) != null) {
|
||||
for (WatchEvent<?> event : key.pollEvents()) {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
Path keyPath = (Path) key.watchable();
|
||||
Path path = keyPath.resolve((Path) event.context());
|
||||
handleWatchEvent(watchService, path, kind);
|
||||
}
|
||||
key.reset();
|
||||
}
|
||||
} catch (ClosedWatchServiceException | InterruptedException __) {
|
||||
// Silently ignore exception, this is expected when service shuts down
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Unexpected error at watch service", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle watch event
|
||||
* @param watchService Watch service
|
||||
* @param path File or directory path
|
||||
* @param kind Event kind
|
||||
*/
|
||||
private void handleWatchEvent(@NotNull WatchService watchService, @NotNull Path path, @NotNull WatchEvent.Kind<?> kind) {
|
||||
synchronized (fileTree) {
|
||||
Map<Path, Long> subtree = fileTree.computeIfAbsent(path.getParent(), k -> new HashMap<>());
|
||||
|
||||
// Handle deletion of files and directories
|
||||
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
|
||||
if (subtree.containsKey(path)) {
|
||||
subtree.remove(path);
|
||||
onFileDeleted(path);
|
||||
} else {
|
||||
unregisterDirectory(path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle creation of directories
|
||||
if (path.toFile().isDirectory()) {
|
||||
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
|
||||
registerDirectory(watchService, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle creation and modification of files
|
||||
// NOTE: in Windows, some file creation events are reported as modifications
|
||||
Long oldModifiedAt = subtree.get(path);
|
||||
long newModifiedAt = path.toFile().lastModified();
|
||||
if (oldModifiedAt == null) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileCreated(path);
|
||||
} else if (newModifiedAt > oldModifiedAt) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileModified(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register directory
|
||||
* @param watchService Watch service
|
||||
* @param path Directory path
|
||||
*/
|
||||
private void registerDirectory(@NotNull WatchService watchService, @NotNull Path path) {
|
||||
// Windows supports listing to events in the entire file tree,
|
||||
// in that case only allow registering the listener on the base path
|
||||
if (IS_WINDOWS && !path.equals(basePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start watching directory for events
|
||||
try {
|
||||
WatchEvent.Kind<?>[] events = new WatchEvent.Kind[]{
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
};
|
||||
WatchEvent.Modifier[] modifiers = IS_WINDOWS ?
|
||||
new WatchEvent.Modifier[]{ExtendedWatchEventModifier.FILE_TREE} :
|
||||
new WatchEvent.Modifier[0];
|
||||
path.register(watchService, events, modifiers);
|
||||
LOGGER.fine("Started watching directory at \"" + path + "\"");
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to register directory", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister directory
|
||||
* @param path Directory path
|
||||
*/
|
||||
private void unregisterDirectory(@NotNull Path path) {
|
||||
synchronized (fileTree) {
|
||||
if (!fileTree.containsKey(path)) {
|
||||
// Already unregistered, can skip work
|
||||
return;
|
||||
}
|
||||
boolean foundFirst = false;
|
||||
Iterator<Map.Entry<Path, Map<Path, Long>>> iter = fileTree.entrySet().iterator();
|
||||
while (iter.hasNext()) {
|
||||
Map.Entry<Path, Map<Path, Long>> entry = iter.next();
|
||||
if (entry.getKey().startsWith(path)) {
|
||||
for (Path childPath : entry.getValue().keySet()) {
|
||||
onFileDeleted(childPath);
|
||||
}
|
||||
foundFirst = true;
|
||||
iter.remove();
|
||||
} else if (foundFirst) {
|
||||
// We can break early because set is alphabetically sorted by key
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +1,117 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeMap;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeItemFrame;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public class ImageFile {
|
||||
public static final String CACHE_EXT = "cache";
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final Map<String, FakeMap[][]> cache = new HashMap<>();
|
||||
public class ImageFile extends SynchronizedFile {
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageFile");
|
||||
private final ConcurrentHashMap<String, Lock> locks = new ConcurrentHashMap<>();
|
||||
private final Map<String, CachedMapsFile> cache = new HashMap<>();
|
||||
private final Map<String, Set<FakeImage>> subscribers = new HashMap<>();
|
||||
private final String name;
|
||||
private final String path;
|
||||
private final String filename;
|
||||
private @Nullable Dimension size;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param name Image file name
|
||||
* @param path Path to image file
|
||||
* @param filename Image filename
|
||||
* @param path Path to image file
|
||||
*/
|
||||
protected ImageFile(String name, String path) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
protected ImageFile(@NotNull String filename, @NotNull Path path) {
|
||||
super(path);
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image file name
|
||||
* @return Image file name
|
||||
* Get image filename
|
||||
* @return Image filename
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered image
|
||||
* @return Buffered image
|
||||
* @throws IOException if not a valid image file
|
||||
* @throws NullPointerException if file not found
|
||||
*/
|
||||
private BufferedImage getBufferedImage() throws Exception {
|
||||
return ImageIO.read(new File(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resized buffered image
|
||||
* @param width New width in pixels
|
||||
* @param height New height in pixels
|
||||
* @return Resized buffered image
|
||||
* @throws IOException if not a valid image file
|
||||
* @throws NullPointerException if file not found
|
||||
*/
|
||||
private BufferedImage getBufferedImage(int width, int height) throws Exception {
|
||||
Image tmp = getBufferedImage().getScaledInstance(width, height, Image.SCALE_FAST);
|
||||
BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
|
||||
// Copy data from temporary to resized instance
|
||||
Graphics2D g2d = resizedImage.createGraphics();
|
||||
g2d.drawImage(tmp, 0, 0, null);
|
||||
g2d.dispose();
|
||||
|
||||
return resizedImage;
|
||||
public @NotNull String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original size in pixels
|
||||
* @return Dimension instance or NULL if not a valid image file
|
||||
*/
|
||||
public Dimension getSize() {
|
||||
try {
|
||||
BufferedImage image = getBufferedImage();
|
||||
return new Dimension(image.getWidth(), image.getHeight());
|
||||
} catch (Exception e) {
|
||||
public synchronized @Nullable Dimension getSize() {
|
||||
if (size != null) {
|
||||
return size;
|
||||
}
|
||||
try (ImageInputStream inputStream = ImageIO.createImageInputStream(read())) {
|
||||
ImageReader reader = ImageIO.getImageReaders(inputStream).next();
|
||||
reader.setInput(inputStream);
|
||||
size = new Dimension(reader.getWidth(0), reader.getHeight(0));
|
||||
reader.dispose();
|
||||
return size;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image last modified time
|
||||
* @return Last modified time in milliseconds, zero in case of error
|
||||
*/
|
||||
public long getLastModified() {
|
||||
try {
|
||||
return Files.getLastModifiedTime(Paths.get(path)).toMillis();
|
||||
} catch (Exception __) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maps and subscribe to maps cache
|
||||
* Get maps and subscribe to them
|
||||
* @param subscriber Fake image instance requesting the maps
|
||||
* @return Bi-dimensional array of maps
|
||||
* @return Cached maps
|
||||
*/
|
||||
public synchronized FakeMap[][] getMapsAndSubscribe(FakeImage subscriber) {
|
||||
@Blocking
|
||||
public @NotNull CachedMapsFile getMapsAndSubscribe(@NotNull FakeImage subscriber) {
|
||||
int width = subscriber.getWidth();
|
||||
int height = subscriber.getHeight();
|
||||
String cacheKey = width + "-" + height;
|
||||
|
||||
// Try to get maps from memory cache
|
||||
if (cache.containsKey(cacheKey)) {
|
||||
return cache.get(cacheKey);
|
||||
// Prevent rendering the same image/dimensions pair multiple times
|
||||
Lock lock = locks.computeIfAbsent(cacheKey, __ -> new ReentrantLock());
|
||||
lock.lock();
|
||||
|
||||
// Get cached maps without locking this instance
|
||||
CachedMapsFile maps = cache.get(cacheKey);
|
||||
if (maps == null) {
|
||||
maps = CachedMapsFile.from(this, width, height);
|
||||
}
|
||||
|
||||
// Try to get maps from disk cache
|
||||
String cacheFilename = name + "." + cacheKey + "." + CACHE_EXT;
|
||||
File cacheFile = Paths.get(plugin.getStorage().getCachePath(), cacheFilename).toFile();
|
||||
if (cacheFile.isFile() && cacheFile.lastModified() >= getLastModified()) {
|
||||
try {
|
||||
FakeMap[][] maps = readMapsFromCacheFile(cacheFile, width, height);
|
||||
// Update state of this instance
|
||||
synchronized (this) {
|
||||
if (cache.get(cacheKey) != maps) {
|
||||
cache.put(cacheKey, maps);
|
||||
return maps;
|
||||
} catch (Exception e) {
|
||||
plugin.warning("Cache file \"" + cacheFile.getAbsolutePath() + "\" is corrupted");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate maps from original image
|
||||
FakeMap[][] matrix = new FakeMap[width][height];
|
||||
try {
|
||||
BufferedImage image = getBufferedImage(width*FakeMap.DIMENSION, height*FakeMap.DIMENSION);
|
||||
int imgWidth = image.getWidth();
|
||||
|
||||
// Convert RGBA pixels to Minecraft color indexes
|
||||
int[] rgbPixels = image.getRGB(
|
||||
0, 0,
|
||||
imgWidth, image.getHeight(),
|
||||
null, 0,
|
||||
imgWidth
|
||||
);
|
||||
byte[] pixels = new byte[rgbPixels.length];
|
||||
IntStream.range(0, rgbPixels.length).parallel().forEach(i -> {
|
||||
pixels[i] = FakeMap.pixelToIndex(rgbPixels[i]);
|
||||
});
|
||||
|
||||
// Instantiate fake maps
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
matrix[col][row] = new FakeMap(pixels, imgWidth, col*FakeMap.DIMENSION, row*FakeMap.DIMENSION);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist in disk cache
|
||||
try {
|
||||
writeMapsToCacheFile(matrix, width, height, cacheFile);
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to write to cache file \"" + cacheFile.getAbsolutePath() + "\"", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
matrix = FakeMap.getErrorMatrix(width, height);
|
||||
plugin.log(Level.SEVERE, "Failed to get image data from file \"" + path + "\"", e);
|
||||
}
|
||||
|
||||
// Update subscribers for given cached maps
|
||||
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>());
|
||||
subscribers.get(cacheKey).add(subscriber);
|
||||
|
||||
// Persist in memory cache and return
|
||||
cache.put(cacheKey, matrix);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read maps from cache file
|
||||
* @param file Cache file
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Bi-dimensional array of maps
|
||||
* @throws IOException if failed to parse cache file
|
||||
*/
|
||||
private FakeMap[][] readMapsFromCacheFile(File file, int width, int height) throws IOException {
|
||||
try (FileInputStream stream = new FileInputStream(file)) {
|
||||
FakeMap[][] matrix = new FakeMap[width][height];
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
byte[] buffer = new byte[FakeMap.DIMENSION*FakeMap.DIMENSION];
|
||||
stream.read(buffer);
|
||||
matrix[col][row] = new FakeMap(buffer);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write maps to cache file
|
||||
* @param maps Bi-dimensional array of maps
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @param file Cache file
|
||||
* @throws IOException if failed to write to cache file
|
||||
*/
|
||||
private void writeMapsToCacheFile(FakeMap[][] maps, int width, int height, File file) throws IOException {
|
||||
try (FileOutputStream stream = new FileOutputStream(file)) {
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
stream.write(maps[col][row].getPixels());
|
||||
}
|
||||
}
|
||||
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>()).add(subscriber);
|
||||
lock.unlock();
|
||||
locks.remove(cacheKey);
|
||||
return maps;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from memory cache
|
||||
* <p>
|
||||
* This method is called by FakeImage instances when they get invalidated by a WorldArea.
|
||||
* By notifying their respective source ImageFile, the latter can clear cached maps from memory when no more
|
||||
* FakeItemFrames are using them.
|
||||
* This method is called by {@link FakeImage} instances when they get invalidated by a world area change.
|
||||
* By notifying their respective source {@link ImageFile}, the latter can clear cached maps from memory when no
|
||||
* more {@link FakeItemFrame}s are using them.
|
||||
* @param subscriber Fake image instance
|
||||
*/
|
||||
public synchronized void unsubscribe(FakeImage subscriber) {
|
||||
public synchronized void unsubscribe(@NotNull FakeImage subscriber) {
|
||||
String cacheKey = subscriber.getWidth() + "-" + subscriber.getHeight();
|
||||
if (!subscribers.containsKey(cacheKey)) return;
|
||||
if (!subscribers.containsKey(cacheKey)) {
|
||||
// Not subscribed to this image file
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove subscriber
|
||||
Set<FakeImage> currentSubscribers = subscribers.get(cacheKey);
|
||||
@@ -234,31 +121,19 @@ public class ImageFile {
|
||||
if (currentSubscribers.isEmpty()) {
|
||||
subscribers.remove(cacheKey);
|
||||
cache.remove(cacheKey);
|
||||
plugin.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + name + ")");
|
||||
LOGGER.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + filename + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache
|
||||
* <p>
|
||||
* Removes all references to pre-generated map sets.
|
||||
* Removes all references to cached map instances.
|
||||
* This way, next time an image is requested to be rendered, maps will be regenerated.
|
||||
*/
|
||||
public synchronized void invalidate() {
|
||||
size = null;
|
||||
cache.clear();
|
||||
|
||||
// Delete disk cache files
|
||||
File[] files = Paths.get(plugin.getStorage().getCachePath()).toFile().listFiles((__, filename) -> {
|
||||
return filename.matches(Pattern.quote(name) + "\\.[0-9]+-[0-9]+\\." + CACHE_EXT);
|
||||
});
|
||||
if (files == null) {
|
||||
plugin.warning("An error occurred when listing cache files for image \"" + name + "\"");
|
||||
return;
|
||||
}
|
||||
for (File file : files) {
|
||||
if (!file.delete()) {
|
||||
plugin.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\"");
|
||||
}
|
||||
}
|
||||
CachedMapsFile.deleteAll(this);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,48 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
import java.util.logging.Level;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
public class ImageStorage {
|
||||
static public final long POLLING_INTERVAL = 20L * 5; // In server ticks
|
||||
static private final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final String basePath;
|
||||
private final String cachePath;
|
||||
private final SortedMap<String, ImageFile> cachedImages = new TreeMap<>();
|
||||
private BukkitTask task;
|
||||
private WatchService watchService;
|
||||
/**
|
||||
* Service for keeping track of image images.
|
||||
* <p>
|
||||
* All files are indexed based on their <b>filename</b>.
|
||||
* Due to recursion, filenames can contain forward slashes (<i>i.e.,</i> "/") and act as relative paths to the base
|
||||
* directory.
|
||||
*/
|
||||
public class ImageStorage extends FileSystemWatcher {
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageStorage");
|
||||
/** Map of registered files indexed by filename */
|
||||
private final SortedMap<String, ImageFile> files = new TreeMap<>();
|
||||
private final Path cachePath;
|
||||
private final String allowedPaths;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param basePath Path to directory containing the images
|
||||
* @param cachePath Path to directory containing the cached image maps
|
||||
* @param basePath Path to directory containing the images
|
||||
* @param cachePath Path to directory containing the cached image maps
|
||||
* @param allowedPaths Allowed paths pattern
|
||||
*/
|
||||
public ImageStorage(String basePath, String cachePath) {
|
||||
this.basePath = basePath;
|
||||
public ImageStorage(@NotNull Path basePath, @NotNull Path cachePath, @NotNull String allowedPaths) {
|
||||
super(basePath);
|
||||
this.cachePath = cachePath;
|
||||
this.allowedPaths = allowedPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base path
|
||||
* @return Base path
|
||||
*/
|
||||
public String getBasePath() {
|
||||
public @NotNull Path getBasePath() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
@@ -42,94 +50,35 @@ public class ImageStorage {
|
||||
* Get cache path
|
||||
* @return Cache path
|
||||
*/
|
||||
public String getCachePath() {
|
||||
public @NotNull Path getCachePath() {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start instance
|
||||
* @throws SecurityException if failed to access filesystem
|
||||
* @throws Exception if failed to start watch service
|
||||
* Start service
|
||||
* @throws RuntimeException if failed to start watch service
|
||||
*/
|
||||
public void start() throws Exception {
|
||||
// Create directories if necessary
|
||||
File directory = new File(basePath);
|
||||
if (directory.mkdirs()) {
|
||||
plugin.info("Created images directory as it did not exist");
|
||||
@Override
|
||||
public void start() throws RuntimeException {
|
||||
// Create base directories if necessary
|
||||
if (basePath.toFile().mkdirs()) {
|
||||
LOGGER.info("Created images directory as it did not exist");
|
||||
}
|
||||
if (new File(cachePath).mkdirs()) {
|
||||
plugin.info("Created cache directory as it did not exist");
|
||||
if (cachePath.toFile().mkdirs()) {
|
||||
LOGGER.info("Created cache directory as it did not exist");
|
||||
}
|
||||
|
||||
// Do initial directory listing
|
||||
for (File file : Objects.requireNonNull(directory.listFiles())) {
|
||||
if (file.isDirectory()) continue;
|
||||
String filename = file.getName();
|
||||
cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath()));
|
||||
}
|
||||
plugin.fine("Found " + cachedImages.size() + " file(s) in images directory");
|
||||
|
||||
// Prepare watch service
|
||||
watchService = FileSystems.getDefault().newWatchService();
|
||||
Path directoryPath = directory.toPath();
|
||||
directoryPath.register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
);
|
||||
|
||||
// Start watching for changes
|
||||
task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
|
||||
WatchKey watchKey = watchService.poll();
|
||||
if (watchKey == null) return;
|
||||
|
||||
watchKey.pollEvents().forEach(event -> {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
File file = directoryPath.resolve((Path) event.context()).toFile();
|
||||
if (file.isDirectory()) return;
|
||||
|
||||
String filename = file.getName();
|
||||
synchronized (this) {
|
||||
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
|
||||
ImageFile imageFile = cachedImages.get(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
cachedImages.remove(filename);
|
||||
}
|
||||
plugin.fine("Detected file deletion at " + filename);
|
||||
} else if (cachedImages.containsKey(filename)) {
|
||||
cachedImages.get(filename).invalidate();
|
||||
plugin.fine("Detected file update at " + filename);
|
||||
} else {
|
||||
cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath()));
|
||||
plugin.fine("Detected file creation at " + filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watchKey.reset();
|
||||
}, POLLING_INTERVAL, POLLING_INTERVAL);
|
||||
plugin.fine("Started watching for file changes in images directory");
|
||||
// Start file system watcher
|
||||
super.start();
|
||||
LOGGER.fine("Found " + files.size() + " file(s) in images directory");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop instance
|
||||
* Stop service
|
||||
*/
|
||||
@Override
|
||||
public void stop() {
|
||||
// Cancel async task
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
|
||||
// Close watch service
|
||||
if (watchService != null) {
|
||||
try {
|
||||
watchService.close();
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.WARNING, "Failed to close watch service", e);
|
||||
}
|
||||
}
|
||||
super.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,15 +86,70 @@ public class ImageStorage {
|
||||
* @return Number of images
|
||||
*/
|
||||
public synchronized int size() {
|
||||
return cachedImages.size();
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all image filenames
|
||||
* @return Sorted array of image filenames
|
||||
* Get image filenames
|
||||
* @param sender Sender instance to filter only allowed images
|
||||
* @return Allowed images
|
||||
*/
|
||||
public synchronized String[] getAllFilenames() {
|
||||
return cachedImages.keySet().toArray(new String[0]);
|
||||
public synchronized @NotNull List<String> getFilenames(@NotNull CommandSender sender) {
|
||||
List<String> response = new ArrayList<>();
|
||||
for (String filename : files.keySet()) {
|
||||
if (isPathAllowed(filename, sender)) {
|
||||
response.add(filename);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is path allowed
|
||||
* @param path Path instance
|
||||
* @param sender Sender instance
|
||||
* @return Whether sender is allowed to access path
|
||||
*/
|
||||
public boolean isPathAllowed(@NotNull Path path, @NotNull CommandSender sender) {
|
||||
return isPathAllowed(pathToFilename(path), sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is path allowed
|
||||
* @param path Path relative to {@link ImageStorage#basePath}
|
||||
* @param sender Sender instance
|
||||
* @return Whether sender is allowed to access path
|
||||
*/
|
||||
public boolean isPathAllowed(@NotNull String path, @NotNull CommandSender sender) {
|
||||
// Find allowed paths pattern
|
||||
String rawPattern = null;
|
||||
if (sender instanceof Player) {
|
||||
rawPattern = Permissions.getVariable("yamipa-allowed-paths", (Player) sender);
|
||||
}
|
||||
if (rawPattern == null) {
|
||||
rawPattern = allowedPaths;
|
||||
}
|
||||
if (rawPattern.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Replace special tokens in pattern
|
||||
if (sender instanceof Player) {
|
||||
Player player = (Player) sender;
|
||||
rawPattern = rawPattern.replaceAll("#player#", Matcher.quoteReplacement(Pattern.quote(player.getName())));
|
||||
rawPattern = rawPattern.replaceAll("#uuid#", player.getUniqueId().toString());
|
||||
} else {
|
||||
rawPattern = rawPattern.replaceAll("#player#", ".+");
|
||||
rawPattern = rawPattern.replaceAll("#uuid#", ".+");
|
||||
}
|
||||
|
||||
// Perform partial match against pattern
|
||||
try {
|
||||
return Pattern.compile(rawPattern).matcher(path).find();
|
||||
} catch (PatternSyntaxException __) {
|
||||
LOGGER.warning("Invalid allowed paths pattern: " + rawPattern);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +157,54 @@ public class ImageStorage {
|
||||
* @param filename Filename
|
||||
* @return Image instance or NULL if not found
|
||||
*/
|
||||
public synchronized ImageFile get(String filename) {
|
||||
return cachedImages.get(filename);
|
||||
public synchronized @Nullable ImageFile get(@NotNull String filename) {
|
||||
return files.get(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert path to filename
|
||||
* @param path File path
|
||||
* @return Relative path used for indexing
|
||||
*/
|
||||
private @NotNull String pathToFilename(@NotNull Path path) {
|
||||
return basePath.relativize(path).toString().replaceAll("\\\\", "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* On file created
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileCreated(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = new ImageFile(filename, path);
|
||||
if (files.putIfAbsent(filename, imageFile) == null) {
|
||||
LOGGER.fine("Registered file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file modified
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileModified(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = files.get(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
LOGGER.fine("Invalidated file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file deleted
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileDeleted(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = files.remove(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
LOGGER.fine("Unregistered file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* An instance of a file stored in a shared filesystem (<i>e.g.,</i> a NFS drive).
|
||||
* All operations are performed in a blocking way.
|
||||
* That is, the instance will acquire a read or write lock before reading or modifying the file.
|
||||
* If the file is already locked by another resource, it will wait until the lock is released
|
||||
* <b>while blocking the thread</b>.
|
||||
*/
|
||||
public class SynchronizedFile {
|
||||
protected final Path path;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to file
|
||||
*/
|
||||
public SynchronizedFile(@NotNull Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file exists
|
||||
* @return Whether file exists or not
|
||||
*/
|
||||
public boolean exists() {
|
||||
return Files.exists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image last modified time
|
||||
* @return Last modified time in milliseconds or <code>0</code> in case of error
|
||||
*/
|
||||
public long getLastModified() {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toMillis();
|
||||
} catch (Exception __) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make directories
|
||||
* <p>
|
||||
* Creates the directory containing this file and its parents if they don't exist.
|
||||
*/
|
||||
public void mkdirs() {
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
} catch (IOException __) {
|
||||
// Silently ignore exception
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file reader
|
||||
* @return Readable stream of the file
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public RandomAccessFile read() throws IOException {
|
||||
return new SynchronizedFileStream(path, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file writer
|
||||
* @return Writable stream of the file
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public RandomAccessFile write() throws IOException {
|
||||
return new SynchronizedFileStream(path, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Blocking file stream implementing {@link RandomAccessFile} for reading and writing data.
|
||||
* It will lock the file for reading/writing when opened, and release the lock when closed.
|
||||
*/
|
||||
public class SynchronizedFileStream extends RandomAccessFile {
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to file
|
||||
* @param readOnly Whether to open stream in read-only mode
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public SynchronizedFileStream(@NotNull Path path, boolean readOnly) throws IOException {
|
||||
super(path.toFile(), readOnly ? "r" : "rw");
|
||||
waitForLock(readOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for lock to be released
|
||||
* @param readOnly Whether to acquire read-only (shared) lock
|
||||
*/
|
||||
@Blocking
|
||||
private void waitForLock(boolean readOnly) throws IOException {
|
||||
FileChannel channel = getChannel();
|
||||
FileLock lock = null;
|
||||
while (lock == null) {
|
||||
try {
|
||||
lock = channel.lock(0L, Long.MAX_VALUE, readOnly);
|
||||
} catch (OverlappingFileLockException e) {
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
} catch (InterruptedException __) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,102 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import com.comphenix.protocol.wrappers.WrappedChatComponent;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.packets.ActionBarPacket;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import java.util.logging.Level;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ActionBar {
|
||||
private static final Logger LOGGER = Logger.getLogger("ActionBar");
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final Player player;
|
||||
private String message;
|
||||
private BukkitTask task = null;
|
||||
|
||||
private ActionBar(@NotNull Player player, @NotNull String message) {
|
||||
this.player = player;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send action bar message
|
||||
* @param player Player who will receive the message
|
||||
* @param message Message to send
|
||||
*/
|
||||
public static void send(Player player, String message) {
|
||||
PacketContainer titlePacket = new PacketContainer(PacketType.Play.Server.TITLE);
|
||||
titlePacket.getTitleActions().write(0, EnumWrappers.TitleAction.ACTIONBAR);
|
||||
titlePacket.getChatComponents().write(0, WrappedChatComponent.fromText(message));
|
||||
try {
|
||||
ProtocolLibrary.getProtocolManager().sendServerPacket(player, titlePacket);
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Failed to send ActionBar to " + player.getName(), e);
|
||||
}
|
||||
public static void send(@NotNull Player player, @NotNull String message) {
|
||||
ActionBar instance = new ActionBar(player, message);
|
||||
instance.sendOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep sending action bar message
|
||||
* @param player Player who will receive the message
|
||||
* @param message Message to send
|
||||
* @return New task instance
|
||||
* @return New instance
|
||||
*/
|
||||
public static BukkitTask repeat(Player player, String message) {
|
||||
return Bukkit.getScheduler().runTaskTimer(plugin, () -> ActionBar.send(player, message), 0L, 40L);
|
||||
public static @NotNull ActionBar repeat(@NotNull Player player, @NotNull String message) {
|
||||
ActionBar instance = new ActionBar(player, message);
|
||||
instance.start();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set message
|
||||
* @param message Message
|
||||
* @return This instance
|
||||
*/
|
||||
public ActionBar setMessage(@NotNull String message) {
|
||||
this.message = message;
|
||||
return sendOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear message
|
||||
* @return This instance
|
||||
*/
|
||||
public ActionBar clear() {
|
||||
message = "";
|
||||
stop();
|
||||
return sendOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message once
|
||||
* @return This instance
|
||||
*/
|
||||
public ActionBar sendOnce() {
|
||||
ActionBarPacket actionBarPacket = new ActionBarPacket();
|
||||
actionBarPacket.setText(message);
|
||||
try {
|
||||
ProtocolLibrary.getProtocolManager().sendServerPacket(player, actionBarPacket);
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to send ActionBar to " + player.getName(), e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sending message indefinitely
|
||||
* @return This instance
|
||||
*/
|
||||
public ActionBar start() {
|
||||
if (task == null) {
|
||||
task = Bukkit.getScheduler().runTaskTimer(plugin, this::sendOnce, 0L, 40L);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop sending message indefinitely
|
||||
* @return This instance
|
||||
*/
|
||||
public ActionBar stop() {
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
task = null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import java.io.FileOutputStream;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* A class for parsing simple CSV files.
|
||||
* It's used for loading and writing Yamipa's configuration file ("image.dat" by default) and does not
|
||||
* support escaping of special characters, as this is not currently needed.
|
||||
*/
|
||||
public class CsvConfiguration {
|
||||
public static final Charset CHARSET = StandardCharsets.UTF_8;
|
||||
public static final String COLUMN_DELIMITER = "/";
|
||||
public static final String COLUMN_DELIMITER = "\t";
|
||||
private final List<String[]> data = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Get rows
|
||||
* @return List of rows
|
||||
*/
|
||||
public List<String[]> getRows() {
|
||||
public @NotNull List<String[]> getRows() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -28,7 +33,7 @@ public class CsvConfiguration {
|
||||
* Add row
|
||||
* @param row Row
|
||||
*/
|
||||
public void addRow(String[] row) {
|
||||
public void addRow(@NotNull String[] row) {
|
||||
data.add(row);
|
||||
}
|
||||
|
||||
@@ -37,14 +42,25 @@ public class CsvConfiguration {
|
||||
* @param path File path
|
||||
* @throws IOException if failed to read file
|
||||
*/
|
||||
public void load(String path) throws IOException {
|
||||
Stream<String> stream = Files.lines(Paths.get(path), CHARSET);
|
||||
stream.forEach(line -> {
|
||||
line = line.trim();
|
||||
if (!line.isEmpty()) {
|
||||
public void load(@NotNull Path path) throws IOException {
|
||||
try (Stream<String> stream = Files.lines(path, CHARSET)) {
|
||||
stream.forEach(line -> {
|
||||
line = line.trim();
|
||||
|
||||
// Ignore empty lines
|
||||
if (line.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate legacy format
|
||||
if (!line.contains(COLUMN_DELIMITER)) {
|
||||
line = line.replaceAll("/", COLUMN_DELIMITER);
|
||||
}
|
||||
|
||||
// Parse line
|
||||
addRow(line.split(COLUMN_DELIMITER));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +68,8 @@ public class CsvConfiguration {
|
||||
* @param path File path
|
||||
* @throws IOException if failed to write file
|
||||
*/
|
||||
public void save(String path) throws IOException {
|
||||
try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(path), CHARSET)) {
|
||||
public void save(@NotNull Path path) throws IOException {
|
||||
try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(path), CHARSET)) {
|
||||
for (String[] row : getRows()) {
|
||||
writer.write(String.join(COLUMN_DELIMITER, row) + "\n");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class DirectionUtils {
|
||||
/**
|
||||
@@ -8,7 +9,7 @@ public class DirectionUtils {
|
||||
* @param yaw Yaw angle
|
||||
* @return Cardinal direction
|
||||
*/
|
||||
public static BlockFace getCardinalDirection(float yaw) {
|
||||
public static @NotNull BlockFace getCardinalDirection(float yaw) {
|
||||
if (yaw < 0) {
|
||||
yaw += 360;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.ListeningWhitelist;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.PacketListener;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class InteractWithEntityListener implements PacketListener {
|
||||
public static final int MAX_BLOCK_DISTANCE = 5; // Server should only accept entities within a 4-block radius
|
||||
private static final Logger LOGGER = Logger.getLogger("InteractWithEntityListener");
|
||||
|
||||
/**
|
||||
* On player attack listener
|
||||
* @param player Initiating player
|
||||
* @param block Target block
|
||||
* @param face Target block face
|
||||
* @return Whether to allow original event (<code>true</code>) or not (<code>false</code>)
|
||||
*/
|
||||
public abstract boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face);
|
||||
|
||||
/**
|
||||
* On player interact listener
|
||||
* @param player Initiating player
|
||||
* @param block Target block
|
||||
* @param face Target block face
|
||||
* @return Whether to allow original event (<code>true</code>) or not (<code>false</code>)
|
||||
*/
|
||||
public abstract boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face);
|
||||
|
||||
/**
|
||||
* Get listener priority
|
||||
* @return Listener priority
|
||||
*/
|
||||
public @NotNull ListenerPriority getPriority() {
|
||||
return ListenerPriority.LOWEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register listener
|
||||
*/
|
||||
public void register() {
|
||||
ProtocolLibrary.getProtocolManager().addPacketListener(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister listener
|
||||
*/
|
||||
public void unregister() {
|
||||
ProtocolLibrary.getProtocolManager().removePacketListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void onPacketReceiving(@NotNull PacketEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
|
||||
// Discard out-of-range packets
|
||||
List<Block> lastTwoTargetBlocks = player.getLastTwoTargetBlocks(null, MAX_BLOCK_DISTANCE);
|
||||
if (lastTwoTargetBlocks.size() != 2) return;
|
||||
Block targetBlock = lastTwoTargetBlocks.get(1);
|
||||
if (!targetBlock.getType().isSolid()) return;
|
||||
|
||||
// Get target block face
|
||||
Block adjacentBlock = lastTwoTargetBlocks.get(0);
|
||||
BlockFace targetBlockFace = targetBlock.getFace(adjacentBlock);
|
||||
if (targetBlockFace == null) return;
|
||||
|
||||
// Get action
|
||||
EnumWrappers.EntityUseAction action;
|
||||
if (Internals.MINECRAFT_VERSION < 17) {
|
||||
action = event.getPacket().getEntityUseActions().read(0);
|
||||
} else {
|
||||
action = event.getPacket().getEnumEntityUseActions().read(0).getAction();
|
||||
}
|
||||
|
||||
// Notify handler synchronously
|
||||
boolean allowEvent = true;
|
||||
try {
|
||||
if (action == EnumWrappers.EntityUseAction.ATTACK) {
|
||||
allowEvent = onAttack(player, targetBlock, targetBlockFace);
|
||||
} else if (action == EnumWrappers.EntityUseAction.INTERACT_AT) {
|
||||
allowEvent = onInteract(player, targetBlock, targetBlockFace);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to notify entity listener handler", e);
|
||||
}
|
||||
|
||||
// Cancel event (if needed)
|
||||
if (!allowEvent) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void onPacketSending(PacketEvent event) {
|
||||
// Intentionally left blank
|
||||
}
|
||||
|
||||
@Override
|
||||
public final ListeningWhitelist getReceivingWhitelist() {
|
||||
return ListeningWhitelist.newBuilder()
|
||||
.priority(getPriority())
|
||||
.types(PacketType.Play.Client.USE_ENTITY)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public final ListeningWhitelist getSendingWhitelist() {
|
||||
return ListeningWhitelist.EMPTY_WHITELIST;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final Plugin getPlugin() {
|
||||
return YamipaPlugin.getInstance();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.CommandMap;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
public class Internals {
|
||||
public static final float MINECRAFT_VERSION;
|
||||
private static final CommandDispatcher<?> DISPATCHER;
|
||||
private static final CommandMap COMMAND_MAP;
|
||||
private static @Nullable Method GET_BUKKIT_SENDER_METHOD = null;
|
||||
|
||||
static {
|
||||
try {
|
||||
// Get Minecraft version
|
||||
String version = Bukkit.getVersion();
|
||||
version = version.substring(version.lastIndexOf("(MC: 1.")+7, version.length()-1);
|
||||
MINECRAFT_VERSION = Float.parseFloat(version);
|
||||
|
||||
// Get "org.bukkit.craftbukkit.CraftServer" references
|
||||
Server obcInstance = Bukkit.getServer();
|
||||
Class<?> obcClass = obcInstance.getClass();
|
||||
|
||||
// Get "net.minecraft.server.MinecraftServer" references
|
||||
Object nmsInstance = obcClass.getDeclaredMethod("getServer").invoke(obcInstance);
|
||||
Class<?> nmsClass = nmsInstance.getClass().getSuperclass();
|
||||
|
||||
if (MINECRAFT_VERSION >= 20.6) {
|
||||
// Get "net.minecraft.commands.Commands" references
|
||||
Object nmsCommandsInstance = nmsClass.getDeclaredMethod("getCommands").invoke(nmsInstance);
|
||||
Class<?> nmsCommandsClass = nmsCommandsInstance.getClass();
|
||||
|
||||
// Get "com.mojang.brigadier.CommandDispatcher" instance
|
||||
Field nmsDispatcherField = nmsCommandsClass.getDeclaredField("dispatcher");
|
||||
nmsDispatcherField.setAccessible(true);
|
||||
DISPATCHER = (CommandDispatcher<?>) nmsDispatcherField.get(nmsCommandsInstance);
|
||||
} else {
|
||||
// Get "net.minecraft.server.CommandDispatcher" references
|
||||
Object nmsDispatcherInstance = nmsClass.getDeclaredField("vanillaCommandDispatcher").get(nmsInstance);
|
||||
Class<?> nmsDispatcherClass = nmsDispatcherInstance.getClass();
|
||||
|
||||
// Get "com.mojang.brigadier.CommandDispatcher" instance
|
||||
Method getDispatcherMethod = nmsDispatcherClass.getDeclaredMethod("a");
|
||||
getDispatcherMethod.setAccessible(true);
|
||||
DISPATCHER = (CommandDispatcher<?>) getDispatcherMethod.invoke(nmsDispatcherInstance);
|
||||
}
|
||||
|
||||
// Get command map instance
|
||||
Field commandMapField = obcClass.getDeclaredField("commandMap");
|
||||
commandMapField.setAccessible(true);
|
||||
COMMAND_MAP = (CommandMap) commandMapField.get(obcInstance);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to get internal classes due to incompatible Minecraft server", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Brigadier command dispatcher instance
|
||||
* @return Command dispatcher instance
|
||||
*/
|
||||
public static @NotNull CommandDispatcher<?> getDispatcher() {
|
||||
return DISPATCHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bukkit command map instance
|
||||
* @return Command map instance
|
||||
*/
|
||||
public static @NotNull CommandMap getCommandMap() {
|
||||
return COMMAND_MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bukkit sender from Brigadier context source
|
||||
* @param source Brigadier command context source
|
||||
* @return Command sender instance
|
||||
*/
|
||||
public static @NotNull CommandSender getBukkitSender(@NotNull Object source) {
|
||||
try {
|
||||
if (GET_BUKKIT_SENDER_METHOD == null) {
|
||||
GET_BUKKIT_SENDER_METHOD = source.getClass().getDeclaredMethod("getBukkitSender");
|
||||
}
|
||||
return (CommandSender) GET_BUKKIT_SENDER_METHOD.invoke(source);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to extract Bukkit sender from source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Custom logging class used by components from the plugin.
|
||||
* It is <i>instance agnostic</i>, that is, the same logger instance will work regardless of whether the plugin
|
||||
* instance has changed (for example, because of the plugin being restarted by a plugin manager).
|
||||
*/
|
||||
public class Logger {
|
||||
private final @Nullable String name;
|
||||
|
||||
/**
|
||||
* Get logger instance
|
||||
* @param name Logger name
|
||||
* @return Logger instance
|
||||
*/
|
||||
public static @NotNull Logger getLogger(@NotNull String name) {
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger instance
|
||||
* @return Logger instance
|
||||
*/
|
||||
public static @NotNull Logger getLogger() {
|
||||
return new Logger(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param name Optional logger name
|
||||
*/
|
||||
private Logger(@Nullable String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
* @param e Optional throwable to log
|
||||
*/
|
||||
private void log(@NotNull Level level, @NotNull String message, @Nullable Throwable e) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
|
||||
// Handle verbose logging levels
|
||||
if (level.intValue() < Level.INFO.intValue()) {
|
||||
if (!plugin.isVerbose()) return;
|
||||
level = Level.INFO;
|
||||
}
|
||||
|
||||
// Add logger name to message
|
||||
if (name != null) {
|
||||
message = "[" + name + "] " + message;
|
||||
}
|
||||
|
||||
// Proxy record to real logger
|
||||
if (e == null) {
|
||||
plugin.getLogger().log(level, message);
|
||||
} else {
|
||||
plugin.getLogger().log(level, message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log severe message
|
||||
* @param message Message
|
||||
* @param e Throwable to log
|
||||
*/
|
||||
public void severe(@NotNull String message, @NotNull Throwable e) {
|
||||
log(Level.SEVERE, message, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
* @param e Throwable to log
|
||||
*/
|
||||
public void warning(@NotNull String message, @NotNull Throwable e) {
|
||||
log(Level.WARNING, message, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
*/
|
||||
public void warning(@NotNull String message) {
|
||||
log(Level.WARNING, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @param message Message
|
||||
*/
|
||||
public void info(@NotNull String message) {
|
||||
log(Level.INFO, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log fine message
|
||||
* @param message Message
|
||||
*/
|
||||
public void fine(@NotNull String message) {
|
||||
log(Level.FINE, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.palmergames.bukkit.towny.TownyAPI;
|
||||
import com.palmergames.bukkit.towny.object.TownyPermission;
|
||||
import com.palmergames.bukkit.towny.utils.PlayerCacheUtil;
|
||||
import com.sk89q.worldedit.bukkit.BukkitAdapter;
|
||||
import com.sk89q.worldguard.LocalPlayer;
|
||||
import com.sk89q.worldguard.WorldGuard;
|
||||
import com.sk89q.worldguard.bukkit.WorldGuardPlugin;
|
||||
import com.sk89q.worldguard.internal.platform.WorldGuardPlatform;
|
||||
import com.sk89q.worldguard.protection.flags.Flags;
|
||||
import com.sk89q.worldguard.protection.flags.StateFlag;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import me.angeschossen.lands.api.LandsIntegration;
|
||||
import me.angeschossen.lands.api.flags.type.RoleFlag;
|
||||
import me.angeschossen.lands.api.land.LandWorld;
|
||||
import me.angeschossen.lands.api.player.LandPlayer;
|
||||
import me.ryanhamshire.GriefPrevention.GriefPrevention;
|
||||
import net.luckperms.api.LuckPerms;
|
||||
import net.luckperms.api.LuckPermsProvider;
|
||||
import org.anjocaido.groupmanager.GroupManager;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
public class Permissions {
|
||||
private static final Logger LOGGER = Logger.getLogger();
|
||||
private static @Nullable LuckPerms luckPerms;
|
||||
private static @Nullable GroupManager groupManager;
|
||||
private static @Nullable WorldGuard worldGuard;
|
||||
private static @Nullable GriefPrevention griefPrevention;
|
||||
private static @Nullable TownyAPI townyApi;
|
||||
private static @Nullable LandsIntegration landsApi;
|
||||
|
||||
static {
|
||||
try {
|
||||
luckPerms = LuckPermsProvider.get();
|
||||
} catch (NoClassDefFoundError | IllegalStateException __) {
|
||||
// LuckPerms is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
groupManager = (GroupManager) YamipaPlugin.getInstance().getServer().getPluginManager().getPlugin("GroupManager");
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// GroupManager is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
worldGuard = WorldGuard.getInstance();
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// WorldGuard is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
griefPrevention = GriefPrevention.instance;
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// GriefPrevention is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
townyApi = TownyAPI.getInstance();
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// Towny is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
landsApi = LandsIntegration.of(YamipaPlugin.getInstance());
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// Lands is not installed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player variable
|
||||
* @param variable Variable name (key)
|
||||
* @param player Player instance
|
||||
* @return Variable value or NULL if not found
|
||||
*/
|
||||
public static @Nullable String getVariable(@NotNull String variable, @NotNull Player player) {
|
||||
if (luckPerms != null) {
|
||||
return luckPerms.getPlayerAdapter(Player.class).getUser(player).getCachedData().getMetaData()
|
||||
.getMetaValue(variable);
|
||||
}
|
||||
|
||||
if (groupManager != null) {
|
||||
String rawValue = groupManager.getWorldsHolder().getWorldPermissions(player)
|
||||
.getPermissionString(player.getName(), variable);
|
||||
return rawValue.isEmpty() ? null : rawValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can build at this block
|
||||
* @param player Player instance
|
||||
* @param location Block location
|
||||
* @return Whether player can build or not
|
||||
*/
|
||||
public static boolean canBuild(@NotNull Player player, @NotNull Location location) {
|
||||
return queryWorldGuard(player, location, true)
|
||||
&& queryGriefPrevention(player, location, true)
|
||||
&& queryTowny(player, location, true)
|
||||
&& queryLands(player, location, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can destroy this block
|
||||
* @param player Player instance
|
||||
* @param location Block location
|
||||
* @return Whether player can destroy or not
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean canDestroy(@NotNull Player player, @NotNull Location location) {
|
||||
return queryWorldGuard(player, location, false)
|
||||
&& queryGriefPrevention(player, location, false)
|
||||
&& queryTowny(player, location, false)
|
||||
&& queryLands(player, location, false);
|
||||
}
|
||||
|
||||
private static boolean queryWorldGuard(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (worldGuard == null) {
|
||||
return true;
|
||||
}
|
||||
WorldGuardPlatform platform = worldGuard.getPlatform();
|
||||
LocalPlayer wrappedPlayer = WorldGuardPlugin.inst().wrapPlayer(player);
|
||||
|
||||
// Grant if bypass permission is enabled
|
||||
boolean hasBypass = platform.getSessionManager().hasBypass(
|
||||
wrappedPlayer,
|
||||
BukkitAdapter.adapt(Objects.requireNonNull(location.getWorld()))
|
||||
);
|
||||
if (hasBypass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permission (note "BUILD" flag must always be present)
|
||||
StateFlag flag = isBuild ? Flags.BLOCK_PLACE : Flags.BLOCK_BREAK;
|
||||
return platform.getRegionContainer().createQuery()
|
||||
.testState(BukkitAdapter.adapt(location), wrappedPlayer, Flags.BUILD, flag);
|
||||
}
|
||||
|
||||
private static boolean queryGriefPrevention(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (griefPrevention == null) {
|
||||
return true;
|
||||
}
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
|
||||
// Build callable depending on permission to check
|
||||
Callable<Boolean> canEditCallable = isBuild ?
|
||||
() -> griefPrevention.allowBuild(player, location) == null :
|
||||
() -> griefPrevention.allowBreak(player, location.getBlock(), location) == null;
|
||||
|
||||
// Check permission from primary thread
|
||||
try {
|
||||
return Bukkit.isPrimaryThread() ?
|
||||
canEditCallable.call() :
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, canEditCallable).get();
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to get player permissions from GriefPrevention", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean queryTowny(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (townyApi == null) {
|
||||
return true;
|
||||
}
|
||||
Material material = location.getBlock().getType();
|
||||
TownyPermission.ActionType type = isBuild ? TownyPermission.ActionType.BUILD : TownyPermission.ActionType.DESTROY;
|
||||
return PlayerCacheUtil.getCachePermission(player, location, material, type);
|
||||
}
|
||||
|
||||
private static boolean queryLands(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (landsApi == null) {
|
||||
return true;
|
||||
}
|
||||
LandWorld landWorld = landsApi.getWorld(Objects.requireNonNull(location.getWorld()));
|
||||
if (landWorld == null) {
|
||||
return true;
|
||||
}
|
||||
LandPlayer landPlayer = Objects.requireNonNull(landsApi.getLandPlayer(player.getUniqueId()));
|
||||
RoleFlag flag = isBuild ?
|
||||
me.angeschossen.lands.api.flags.type.Flags.BLOCK_PLACE :
|
||||
me.angeschossen.lands.api.flags.type.Flags.BLOCK_BREAK;
|
||||
return landWorld.hasRoleFlag(landPlayer, location, flag, null, false);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.events.ListeningWhitelist;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.PacketListener;
|
||||
import com.comphenix.protocol.wrappers.EnumWrappers;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
@@ -19,28 +14,27 @@ import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
public class SelectBlockTask {
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private static final Logger LOGGER = Logger.getLogger("SelectBlockTask");
|
||||
private static final Map<UUID, SelectBlockTask> instances = new HashMap<>();
|
||||
private static PlayerInteractionListener listener = null;
|
||||
private static SelectBlockTaskListener listener = null;
|
||||
private final Player player;
|
||||
private BiConsumer<Location, BlockFace> success;
|
||||
private Runnable failure;
|
||||
private BukkitTask actionBarTask;
|
||||
private ActionBar actionBar;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param player Target player instance
|
||||
*/
|
||||
public SelectBlockTask(Player player) {
|
||||
public SelectBlockTask(@NotNull Player player) {
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
@@ -48,7 +42,7 @@ public class SelectBlockTask {
|
||||
* Set on success callback
|
||||
* @param callback Success callback
|
||||
*/
|
||||
public void onSuccess(BiConsumer<Location, BlockFace> callback) {
|
||||
public void onSuccess(@Nullable BiConsumer<@NotNull Location, @NotNull BlockFace> callback) {
|
||||
this.success = callback;
|
||||
}
|
||||
|
||||
@@ -56,7 +50,7 @@ public class SelectBlockTask {
|
||||
* Set on failure (e.g. canceled) callback
|
||||
* @param callback Failure callback
|
||||
*/
|
||||
public void onFailure(Runnable callback) {
|
||||
public void onFailure(@Nullable Runnable callback) {
|
||||
this.failure = callback;
|
||||
}
|
||||
|
||||
@@ -64,7 +58,7 @@ public class SelectBlockTask {
|
||||
* Run task
|
||||
* @param helpMessage Help message for the player
|
||||
*/
|
||||
public void run(String helpMessage) {
|
||||
public void run(@NotNull String helpMessage) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
|
||||
// Has this player another active task?
|
||||
@@ -75,72 +69,81 @@ public class SelectBlockTask {
|
||||
|
||||
// Create listener singleton if needed
|
||||
if (listener == null) {
|
||||
listener = new PlayerInteractionListener();
|
||||
plugin.getServer().getPluginManager().registerEvents(listener, plugin);
|
||||
ProtocolLibrary.getProtocolManager().addPacketListener(listener);
|
||||
plugin.fine("Created PlayerInteractionListener singleton");
|
||||
listener = new SelectBlockTaskListener();
|
||||
listener.register();
|
||||
LOGGER.fine("Created SelectBlockTaskListener singleton");
|
||||
}
|
||||
|
||||
// Start task
|
||||
instances.put(uuid, this);
|
||||
actionBarTask = ActionBar.repeat(player, ChatColor.GREEN + helpMessage + ChatColor.RESET +
|
||||
" — " + ChatColor.RED + "Left click to cancel");
|
||||
actionBar = ActionBar.repeat(player, ChatColor.GREEN + helpMessage + ChatColor.RESET +
|
||||
" - " + ChatColor.RED + "Left click to cancel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel task
|
||||
*/
|
||||
public void cancel() {
|
||||
if (actionBarTask != null) {
|
||||
actionBarTask.cancel();
|
||||
if (actionBar != null) {
|
||||
actionBar.clear();
|
||||
actionBar = null;
|
||||
}
|
||||
instances.remove(player.getUniqueId());
|
||||
|
||||
// Destroy listener singleton if no more active tasks
|
||||
if (instances.isEmpty()) {
|
||||
HandlerList.unregisterAll(listener);
|
||||
ProtocolLibrary.getProtocolManager().removePacketListener(listener);
|
||||
listener.unregister();
|
||||
listener = null;
|
||||
plugin.fine("Destroyed PlayerInteractionListener singleton");
|
||||
LOGGER.fine("Destroyed SelectBlockTaskListener singleton");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal listener for handling player events
|
||||
*/
|
||||
private static class PlayerInteractionListener implements Listener, PacketListener {
|
||||
@EventHandler
|
||||
public void onPlayerInteraction(PlayerInteractEvent event) {
|
||||
private static class SelectBlockTaskListener extends InteractWithEntityListener implements Listener {
|
||||
@Override
|
||||
public void register() {
|
||||
super.register();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
super.unregister();
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull ListenerPriority getPriority() {
|
||||
return ListenerPriority.LOW;
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOW)
|
||||
public void onBlockInteraction(@NotNull PlayerInteractEvent event) {
|
||||
Action action = event.getAction();
|
||||
Player player = event.getPlayer();
|
||||
Block block = event.getClickedBlock();
|
||||
if (block == null) return;
|
||||
BlockFace face = event.getBlockFace();
|
||||
|
||||
// Get task responsible for handling this event
|
||||
UUID uuid = event.getPlayer().getUniqueId();
|
||||
SelectBlockTask task = instances.get(uuid);
|
||||
if (task == null) return;
|
||||
|
||||
// Player canceled the task
|
||||
// Handle event
|
||||
boolean allowEvent = true;
|
||||
if (action == Action.LEFT_CLICK_AIR || action == Action.LEFT_CLICK_BLOCK) {
|
||||
event.setCancelled(true);
|
||||
task.cancel();
|
||||
if (task.failure != null) {
|
||||
task.failure.run();
|
||||
}
|
||||
return;
|
||||
allowEvent = handle(player, null, null);
|
||||
} else if (action == Action.RIGHT_CLICK_BLOCK) {
|
||||
allowEvent = handle(player, block, face);
|
||||
}
|
||||
|
||||
// Player selected a block
|
||||
if (action == Action.RIGHT_CLICK_BLOCK && block != null) {
|
||||
// Cancel event (if needed)
|
||||
if (!allowEvent) {
|
||||
event.setCancelled(true);
|
||||
task.cancel();
|
||||
if (task.success != null) {
|
||||
task.success.accept(block.getLocation(), event.getBlockFace());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
|
||||
UUID uuid = event.getPlayer().getUniqueId();
|
||||
SelectBlockTask task = instances.get(uuid);
|
||||
if (task != null) {
|
||||
@@ -149,61 +152,37 @@ public class SelectBlockTask {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPacketReceiving(PacketEvent event) {
|
||||
EnumWrappers.EntityUseAction action = event.getPacket().getEntityUseActions().read(0);
|
||||
Player player = event.getPlayer();
|
||||
public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
|
||||
return handle(player, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
|
||||
return handle(player, block, face);
|
||||
}
|
||||
|
||||
private boolean handle(@NotNull Player player, @Nullable Block block, @Nullable BlockFace face) {
|
||||
// Get task responsible for handling this event
|
||||
UUID uuid = player.getUniqueId();
|
||||
SelectBlockTask task = instances.get(uuid);
|
||||
if (task == null) return;
|
||||
if (task == null) return true;
|
||||
|
||||
// Player left clicked an entity
|
||||
if (action == EnumWrappers.EntityUseAction.ATTACK) {
|
||||
event.setCancelled(true);
|
||||
task.cancel();
|
||||
// Cancel task
|
||||
task.cancel();
|
||||
|
||||
// Notify failure listener
|
||||
if (block == null || face == null) {
|
||||
if (task.failure != null) {
|
||||
task.failure.run();
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Player right clicked an entity
|
||||
if (action == EnumWrappers.EntityUseAction.INTERACT_AT) {
|
||||
int maxDistance = 5; // Server should only accept entities within a 4-block radius
|
||||
List<Block> lastTwoTargetBlocks = player.getLastTwoTargetBlocks(null, maxDistance);
|
||||
if (lastTwoTargetBlocks.size() != 2) return;
|
||||
Block targetBlock = lastTwoTargetBlocks.get(1);
|
||||
Block adjacentBlock = lastTwoTargetBlocks.get(0);
|
||||
if (!targetBlock.getType().isOccluding()) return;
|
||||
|
||||
BlockFace targetBlockFace = targetBlock.getFace(adjacentBlock);
|
||||
event.setCancelled(true);
|
||||
task.cancel();
|
||||
if (task.success != null) {
|
||||
task.success.accept(targetBlock.getLocation(), targetBlockFace);
|
||||
}
|
||||
// Notify success listener
|
||||
if (task.success != null) {
|
||||
task.success.accept(block.getLocation(), face);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPacketSending(PacketEvent event) {
|
||||
// Intentionally left blank
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListeningWhitelist getReceivingWhitelist() {
|
||||
return ListeningWhitelist.newBuilder().types(PacketType.Play.Client.USE_ENTITY).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListeningWhitelist getSendingWhitelist() {
|
||||
return ListeningWhitelist.EMPTY_WHITELIST;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plugin getPlugin() {
|
||||
return plugin;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,103 @@
|
||||
name: ${project.name}
|
||||
version: ${project.version}
|
||||
main: io.josemmo.bukkit.plugin.YamipaPlugin
|
||||
api-version: 1.13
|
||||
api-version: 1.16
|
||||
depend: [ProtocolLib]
|
||||
softdepend: [Multiverse-Core]
|
||||
softdepend:
|
||||
- BentoBox
|
||||
- GriefPrevention
|
||||
- GroupManager
|
||||
- Hyperverse
|
||||
- LuckPerms
|
||||
- Multiverse-Core
|
||||
- My_Worlds
|
||||
- PhantomWorlds
|
||||
- Towny
|
||||
- WorldGuard
|
||||
|
||||
permissions:
|
||||
# All plugin permissions
|
||||
yamipa.*:
|
||||
default: op
|
||||
children:
|
||||
yamipa.command.*: true
|
||||
yamipa.item.*: true
|
||||
# Legacy children
|
||||
yamipa.clear: true
|
||||
yamipa.describe: true
|
||||
yamipa.download: true
|
||||
yamipa.give: true
|
||||
yamipa.list: true
|
||||
yamipa.place: true
|
||||
yamipa.remove: true
|
||||
yamipa.remove.radius: true
|
||||
yamipa.top: true
|
||||
|
||||
# All command permissions
|
||||
yamipa.command.*:
|
||||
default: op
|
||||
children:
|
||||
yamipa.command.clear: true
|
||||
yamipa.command.describe: true
|
||||
yamipa.command.download: true
|
||||
yamipa.command.give: true
|
||||
yamipa.command.list: true
|
||||
yamipa.command.place: true
|
||||
yamipa.command.remove: true
|
||||
yamipa.command.top: true
|
||||
|
||||
# Command permissions
|
||||
yamipa.command.clear:
|
||||
default: op
|
||||
yamipa.command.describe:
|
||||
default: op
|
||||
yamipa.command.download:
|
||||
default: op
|
||||
yamipa.command.give:
|
||||
default: op
|
||||
yamipa.command.list:
|
||||
default: op
|
||||
yamipa.command.place:
|
||||
default: op
|
||||
yamipa.command.remove:
|
||||
default: op
|
||||
children:
|
||||
yamipa.command.remove.own: true
|
||||
yamipa.command.remove.own:
|
||||
default: op
|
||||
yamipa.command.top:
|
||||
default: op
|
||||
|
||||
# All item permissions
|
||||
yamipa.item.*:
|
||||
default: true
|
||||
children:
|
||||
yamipa.item.place: true
|
||||
yamipa.item.remove: true
|
||||
|
||||
# Item permissions
|
||||
yamipa.item.place:
|
||||
default: true
|
||||
yamipa.item.remove:
|
||||
default: true
|
||||
children:
|
||||
yamipa.item.remove.own: true
|
||||
yamipa.item.remove.own:
|
||||
default: true
|
||||
|
||||
# Legacy permissions
|
||||
yamipa.clear:
|
||||
default: op
|
||||
yamipa.describe:
|
||||
default: op
|
||||
yamipa.download:
|
||||
default: op
|
||||
yamipa.give:
|
||||
default: op
|
||||
yamipa.list:
|
||||
default: op
|
||||
yamipa.place:
|
||||
default: op
|
||||
yamipa.remove:
|
||||
default: op
|
||||
yamipa.remove.radius:
|
||||
yamipa.top:
|
||||
default: op
|
||||
Reference in new issue
Block a user