Skip to content

Commit 396167d

Browse files
committed
CrateKeys: animated reward crates for Paper 1.21+.
0 parents  commit 396167d

12 files changed

Lines changed: 813 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
target/
2+
.idea/
3+
*.iml
4+
.vscode/
5+
.DS_Store

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# CrateKeys
2+
3+
Reward **crates** with collectible keys and an **animated roulette** opening, for Paper 1.21+.
4+
Bind any block as a crate, hand out keys, and let players right-click to spin for weighted rewards.
5+
6+
## Download
7+
8+
**[Download the latest release »](https://github.com/Standardan/cratekeys/releases/latest)**
9+
10+
Drop the `.jar` into your server's `plugins/` folder and restart. Requires Paper 1.21+ (Java 21).
11+
12+
## Features
13+
14+
- **Config-driven crates** — define crates, key items, and weighted reward tables in `config.yml`
15+
- **Animated spin GUI** — icons scroll across a roulette and land on the winning reward, with sound
16+
- **Tagged key items** — keys are identified by a PersistentDataContainer tag, not their name
17+
- **Block-bound crates**`/crate setblock <crate>` turns any block into a crate; right-click with a key opens it
18+
- **Weighted rewards** run as console commands (`give`, `eco`, anything), so rewards are unlimited
19+
- The spin GUI is **click-locked** so players can't pull items out mid-animation
20+
21+
## Commands (`cratekeys.admin`, default op)
22+
23+
| Command | Description |
24+
|---|---|
25+
| `/crate givekey <player> <crate> [amount]` | Give crate keys |
26+
| `/crate setblock <crate>` | Bind the block you're looking at as a crate |
27+
| `/crate removeblock` | Unbind a crate block |
28+
| `/crate reload` | Reload config |
29+
| `/crate list` | List defined crates |
30+
31+
## Configuration
32+
33+
```yaml
34+
crates:
35+
vote:
36+
display-name: "<aqua><bold>Vote Crate</bold>"
37+
key-name: "<aqua>Vote Key"
38+
key-material: TRIPWIRE_HOOK
39+
rewards:
40+
- name: "<white>16 Diamonds"
41+
weight: 50 # relative odds
42+
icon: DIAMOND
43+
commands:
44+
- "give %player% diamond 16"
45+
```
46+
47+
## Design notes
48+
49+
- **The winner is decided before the animation starts** (`Crate#roll()` does a single weighted draw).
50+
The roulette is pure presentation — it can't change the outcome, which keeps rewards honest and
51+
the code simple.
52+
- **The GUI is identified by a custom `InventoryHolder`** (`SpinGui`), so the click listener cancels
53+
interaction by *type*, never by fragile title-string matching.
54+
- **Animation runs on a repeating scheduler task** at a fixed tick interval and cancels itself when done.
55+
56+
## Building
57+
58+
JDK 21 + Maven. `mvn clean package` → `target/cratekeys-1.0.0.jar`.

pom.xml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<groupId>io.github.standardan</groupId>
8+
<artifactId>cratekeys</artifactId>
9+
<version>1.0.0</version>
10+
<packaging>jar</packaging>
11+
12+
<properties>
13+
<maven.compiler.release>21</maven.compiler.release>
14+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
15+
</properties>
16+
17+
<repositories>
18+
<repository>
19+
<id>papermc</id>
20+
<url>https://repo.papermc.io/repository/maven-public/</url>
21+
</repository>
22+
</repositories>
23+
24+
<dependencies>
25+
<dependency>
26+
<groupId>io.papermc.paper</groupId>
27+
<artifactId>paper-api</artifactId>
28+
<version>1.21.4-R0.1-SNAPSHOT</version>
29+
<scope>provided</scope>
30+
</dependency>
31+
</dependencies>
32+
33+
<build>
34+
<finalName>${project.artifactId}-${project.version}</finalName>
35+
<resources>
36+
<resource>
37+
<directory>src/main/resources</directory>
38+
<filtering>true</filtering>
39+
</resource>
40+
</resources>
41+
<plugins>
42+
<plugin>
43+
<groupId>org.apache.maven.plugins</groupId>
44+
<artifactId>maven-compiler-plugin</artifactId>
45+
<version>3.13.0</version>
46+
</plugin>
47+
</plugins>
48+
</build>
49+
</project>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.github.standardan.cratekeys;
2+
3+
import io.github.standardan.cratekeys.command.CrateCommand;
4+
import io.github.standardan.cratekeys.crate.CrateManager;
5+
import org.bukkit.command.PluginCommand;
6+
import org.bukkit.plugin.java.JavaPlugin;
7+
8+
import java.util.Objects;
9+
10+
public final class CrateKeysPlugin extends JavaPlugin {
11+
12+
@Override
13+
public void onEnable() {
14+
saveDefaultConfig();
15+
16+
CrateManager manager = new CrateManager(this);
17+
manager.load();
18+
19+
getServer().getPluginManager().registerEvents(new CrateListener(this, manager), this);
20+
21+
PluginCommand command = Objects.requireNonNull(getCommand("crate"), "crate missing from plugin.yml");
22+
CrateCommand handler = new CrateCommand(this, manager);
23+
command.setExecutor(handler);
24+
command.setTabCompleter(handler);
25+
26+
getLogger().info("CrateKeys enabled with " + manager.crates().size() + " crate(s).");
27+
}
28+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.github.standardan.cratekeys;
2+
3+
import io.github.standardan.cratekeys.crate.Crate;
4+
import io.github.standardan.cratekeys.crate.CrateManager;
5+
import io.github.standardan.cratekeys.gui.SpinGui;
6+
import net.kyori.adventure.text.Component;
7+
import net.kyori.adventure.text.format.NamedTextColor;
8+
import org.bukkit.block.Block;
9+
import org.bukkit.entity.Player;
10+
import org.bukkit.event.EventHandler;
11+
import org.bukkit.event.Listener;
12+
import org.bukkit.event.block.Action;
13+
import org.bukkit.event.inventory.InventoryClickEvent;
14+
import org.bukkit.event.inventory.InventoryDragEvent;
15+
import org.bukkit.event.player.PlayerInteractEvent;
16+
import org.bukkit.inventory.EquipmentSlot;
17+
import org.bukkit.inventory.ItemStack;
18+
import org.bukkit.plugin.Plugin;
19+
20+
/**
21+
* Opens crates when a player right-clicks a bound crate block with the right
22+
* key, and locks the spin GUI so nothing can be clicked out of it.
23+
*/
24+
public final class CrateListener implements Listener {
25+
26+
private final Plugin plugin;
27+
private final CrateManager manager;
28+
29+
public CrateListener(Plugin plugin, CrateManager manager) {
30+
this.plugin = plugin;
31+
this.manager = manager;
32+
}
33+
34+
@EventHandler
35+
public void onInteract(PlayerInteractEvent event) {
36+
if (event.getHand() != EquipmentSlot.HAND || event.getAction() != Action.RIGHT_CLICK_BLOCK) {
37+
return;
38+
}
39+
Block block = event.getClickedBlock();
40+
if (block == null) {
41+
return;
42+
}
43+
Crate crate = manager.getCrateAtBlock(block.getLocation());
44+
if (crate == null) {
45+
return; // not a crate block - ignore
46+
}
47+
event.setCancelled(true); // don't open the underlying chest, etc.
48+
49+
Player player = event.getPlayer();
50+
if (manager.isSpinning(player.getUniqueId())) {
51+
return;
52+
}
53+
ItemStack hand = player.getInventory().getItemInMainHand();
54+
Crate keyCrate = manager.getCrateFromKey(hand);
55+
if (keyCrate == null || !keyCrate.id().equals(crate.id())) {
56+
player.sendMessage(Component.text("You need a ", NamedTextColor.RED)
57+
.append(crate.keyName())
58+
.append(Component.text(" to open this.", NamedTextColor.RED)));
59+
return;
60+
}
61+
62+
hand.setAmount(hand.getAmount() - 1); // consume one key
63+
new SpinGui(plugin, manager, player, crate).open();
64+
}
65+
66+
@EventHandler
67+
public void onClick(InventoryClickEvent event) {
68+
if (event.getInventory().getHolder() instanceof SpinGui) {
69+
event.setCancelled(true); // can't take items mid-spin
70+
}
71+
}
72+
73+
@EventHandler
74+
public void onDrag(InventoryDragEvent event) {
75+
if (event.getInventory().getHolder() instanceof SpinGui) {
76+
event.setCancelled(true);
77+
}
78+
}
79+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package io.github.standardan.cratekeys.command;
2+
3+
import io.github.standardan.cratekeys.CrateKeysPlugin;
4+
import io.github.standardan.cratekeys.crate.Crate;
5+
import io.github.standardan.cratekeys.crate.CrateManager;
6+
import net.kyori.adventure.text.Component;
7+
import net.kyori.adventure.text.format.NamedTextColor;
8+
import org.bukkit.Bukkit;
9+
import org.bukkit.block.Block;
10+
import org.bukkit.command.Command;
11+
import org.bukkit.command.CommandExecutor;
12+
import org.bukkit.command.CommandSender;
13+
import org.bukkit.command.TabCompleter;
14+
import org.bukkit.entity.Player;
15+
import org.jetbrains.annotations.NotNull;
16+
17+
import java.util.ArrayList;
18+
import java.util.List;
19+
import java.util.Locale;
20+
21+
/**
22+
* Admin command: give keys, bind/unbind crate blocks, reload, list.
23+
*/
24+
public final class CrateCommand implements CommandExecutor, TabCompleter {
25+
26+
private final CrateKeysPlugin plugin;
27+
private final CrateManager manager;
28+
29+
public CrateCommand(CrateKeysPlugin plugin, CrateManager manager) {
30+
this.plugin = plugin;
31+
this.manager = manager;
32+
}
33+
34+
@Override
35+
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
36+
@NotNull String label, @NotNull String[] args) {
37+
if (args.length == 0) {
38+
sender.sendMessage(Component.text("/crate <givekey|setblock|removeblock|reload|list>",
39+
NamedTextColor.GRAY));
40+
return true;
41+
}
42+
switch (args[0].toLowerCase(Locale.ROOT)) {
43+
case "givekey" -> giveKey(sender, args);
44+
case "setblock" -> setBlock(sender, args);
45+
case "removeblock" -> removeBlock(sender);
46+
case "reload" -> {
47+
plugin.reloadConfig();
48+
manager.load();
49+
sender.sendMessage(Component.text("CrateKeys reloaded.", NamedTextColor.GREEN));
50+
}
51+
case "list" -> {
52+
String ids = manager.crates().stream().map(Crate::id).reduce((a, b) -> a + ", " + b).orElse("(none)");
53+
sender.sendMessage(Component.text("Crates: " + ids, NamedTextColor.AQUA));
54+
}
55+
default -> sender.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED));
56+
}
57+
return true;
58+
}
59+
60+
private void giveKey(CommandSender sender, String[] args) {
61+
if (args.length < 3) {
62+
sender.sendMessage(Component.text("Usage: /crate givekey <player> <crate> [amount]", NamedTextColor.RED));
63+
return;
64+
}
65+
Player target = Bukkit.getPlayerExact(args[1]);
66+
if (target == null) {
67+
sender.sendMessage(Component.text("Player not online.", NamedTextColor.RED));
68+
return;
69+
}
70+
Crate crate = manager.getCrate(args[2]);
71+
if (crate == null) {
72+
sender.sendMessage(Component.text("No crate named '" + args[2] + "'.", NamedTextColor.RED));
73+
return;
74+
}
75+
int amount = 1;
76+
if (args.length >= 4) {
77+
try {
78+
amount = Math.max(1, Integer.parseInt(args[3]));
79+
} catch (NumberFormatException ignored) {
80+
sender.sendMessage(Component.text("Amount must be a number.", NamedTextColor.RED));
81+
return;
82+
}
83+
}
84+
target.getInventory().addItem(manager.createKey(crate, amount));
85+
sender.sendMessage(Component.text("Gave " + amount + " " + crate.id() + " key(s) to "
86+
+ target.getName() + ".", NamedTextColor.GREEN));
87+
}
88+
89+
private void setBlock(CommandSender sender, String[] args) {
90+
if (!(sender instanceof Player player)) {
91+
sender.sendMessage("Run this in-game, looking at a block.");
92+
return;
93+
}
94+
if (args.length < 2) {
95+
player.sendMessage(Component.text("Usage: /crate setblock <crate>", NamedTextColor.RED));
96+
return;
97+
}
98+
Crate crate = manager.getCrate(args[1]);
99+
if (crate == null) {
100+
player.sendMessage(Component.text("No crate named '" + args[1] + "'.", NamedTextColor.RED));
101+
return;
102+
}
103+
Block block = player.getTargetBlockExact(5);
104+
if (block == null) {
105+
player.sendMessage(Component.text("Look at a block within 5 blocks.", NamedTextColor.RED));
106+
return;
107+
}
108+
manager.bindBlock(block.getLocation(), crate.id());
109+
player.sendMessage(Component.text("Bound that block as a " + crate.id() + " crate.", NamedTextColor.GREEN));
110+
}
111+
112+
private void removeBlock(CommandSender sender) {
113+
if (!(sender instanceof Player player)) {
114+
sender.sendMessage("Run this in-game, looking at a crate block.");
115+
return;
116+
}
117+
Block block = player.getTargetBlockExact(5);
118+
if (block == null || !manager.unbindBlock(block.getLocation())) {
119+
player.sendMessage(Component.text("That block isn't a crate.", NamedTextColor.RED));
120+
return;
121+
}
122+
player.sendMessage(Component.text("Removed that crate block.", NamedTextColor.GREEN));
123+
}
124+
125+
@Override
126+
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
127+
@NotNull String alias, @NotNull String[] args) {
128+
if (args.length == 1) {
129+
return filter(List.of("givekey", "setblock", "removeblock", "reload", "list"), args[0]);
130+
}
131+
if (args.length == 2 && args[0].equalsIgnoreCase("setblock")) {
132+
return filter(crateIds(), args[1]);
133+
}
134+
if (args.length == 3 && args[0].equalsIgnoreCase("givekey")) {
135+
return filter(crateIds(), args[2]);
136+
}
137+
return List.of();
138+
}
139+
140+
private List<String> crateIds() {
141+
List<String> ids = new ArrayList<>();
142+
manager.crates().forEach(c -> ids.add(c.id()));
143+
return ids;
144+
}
145+
146+
private List<String> filter(List<String> options, String prefix) {
147+
return options.stream().filter(s -> s.startsWith(prefix.toLowerCase(Locale.ROOT))).toList();
148+
}
149+
}

0 commit comments

Comments
 (0)