diff --git a/README.md b/README.md index 7a80dad..4c82cf8 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,30 @@ # ChestShop [![](https://poggit.pmmp.io/shield.state/ChestShop)](https://poggit.pmmp.io/p/ChestShop) -ChestShop for PocketMine-MP (pmmp) by Muqsit. Note that you must have **EconomyAPI** installed before running the plugin, else the plugin won't enable. Chest shop allows you to create Chest GUI based shops - a widely used feature in minigames such as MoneyWars, SkyBlock and SkyWars. If you are looking for a compressed .phar file, go here: https://poggit.pmmp.io/ci/Muqsit/ChestShop/ChestShop -Features: -- Ability to add enchanted, custom named, custom NBT tagged items. -- Everything can be managed in-game on run time. No config modification needed. -- Two papers, named "Turn Left" and "Turn Right" are located at the end of the GUI to switch pages. -- ChestGUI (block and inventory) is only sent to the command issuer. The GUI tile block is unbreakable, making it impossible for anyone to duplicate the chest contents. -- Usage of custom chest tiles and inventories rather than bulk events for performance. - -Commands: -- /cs - Opens the ChestShop. (Permission: Everyone) -- /cs add [price] - Add the item in your hand to the ChestShop. (Permission: OP) -- /cs remove [page] [slot] - Remove item from a specific page and slot. (Permission: OP) -- /cs removebyid [itemid] [itemdamage] - Remove items with id [itemid] and damage [itemdamage] off ChestShop. (Permission: OP) -- /cs reload - Reloads the plugin (use this if you experience issues). -- /cs help - List all commands with their descriptions. - -That's it. Dont forget to star the repository. +**NOTE:** ChestShop depends upon EconomyAPI for transactions, you must have **EconomyAPI** plugin installed before running the plugin. + +### Basic Features +- Ability to sell items along with their NBT tags. +- Categories! Create shop categories to sort out your items. +- Optional double-tap-to-buy feature for those who value their money. + +### How To Use? +Download the compiled .phar file from poggit and drop it into your server's `plugins/` folder. +**NOTE:** You must either be OP or have the permission `chestshop.command.admin` to use the commands: `/cs addcategory`, `/cs removecategory` and `/cs additem` + +#### Adding a category +Categories are the front-page of the `/chestshop` command. If you do not have any categories, the `/chestshop` command will send you an empty chest GUI. To add a category, use `/cs addcategory ` while holding an item. The item that you are holding will be used to represent the category in `/chestshop`. +![](https://i.imgur.com/8cPouEf.png) + +Now let's see how `/chestshop` looks. + +![](https://imgur.com/iRWAJ6a.png) + +Neato! Let's add some items to our category using `/cs additem `. + +![](https://i.imgur.com/fF8gPap.png) + +Awesome! You might be wondering what the 2 papers and one chest is doing in the last row of the GUI. The two papers turn towards the left/right page in case you have more than 45 items in your category. The chest in the middle of the two papers bring you back to the list of categories (`/chestshop`). diff --git a/plugin.yml b/plugin.yml index 857b135..8614d1e 100644 --- a/plugin.yml +++ b/plugin.yml @@ -1,8 +1,7 @@ name: ChestShop -main: ChestShop\Main +main: muqsit\chestshop\ChestShop api: 3.0.0-ALPHA11 -version: 3.0 -depend: [EconomyAPI] +version: 4 commands: chestshop: aliases: ["cs", "cshop"] diff --git a/resources/shops.yml b/resources/buttons.yml similarity index 100% rename from resources/shops.yml rename to resources/buttons.yml diff --git a/resources/config.yml b/resources/config.yml index 60e585d..2db4c56 100644 --- a/resources/config.yml +++ b/resources/config.yml @@ -1,9 +1,4 @@ --- -# These items cannot be sold in-game using "/cs add". -banned-items: -- 35:1 -- 7 - -# Whether to enable the "/cs synceconomy" command. -'enable-sync': false -... +# Whether to have players double-tap items in the GUI to purchase. +double-tapping: false +... \ No newline at end of file diff --git a/src/ChestShop/Main.php b/src/ChestShop/Main.php deleted file mode 100644 index 5b0085b..0000000 --- a/src/ChestShop/Main.php +++ /dev/null @@ -1,441 +0,0 @@ -getLogger()->notice(implode("\n", [ - " ", - " _____ _ _ _____ _ ", - "/ __ \ | | | / ___| | ", - "| / \/ |__ ___ ___| |_\ `--.| |__ ___ _ __ ", - "| | | '_ \ / _ \/ __| __|`--. \ '_ \ / _ \| '_ \ ", - "| \__/\ | | | __/\__ \ |_/\__/ / | | | (_) | |_) |", - " \____/_| |_|\___||___/\__\____/|_| |_|\___/| .__/ ", - " | | ", - " |_| ", - " ", - "@author Muqsit Rayyan", - " " - ])); - - if(!is_dir($this->getDataFolder())){ - mkdir($this->getDataFolder()); - } - - $this->updateConfig("config.yml"); - $this->updateConfig("shops.yml"); - - $shops = yaml_parse_file($this->getDataFolder().'shops.yml'); - if(!empty($shops)){ - $this->shops = $shops; - } - - $config = yaml_parse_file($this->getDataFolder().'config.yml'); - - $this->loadBannedItemsList($config["banned-items"]); - - if($config['enable-sync']){ - $this->economyshop = $this->getServer()->getPluginManager()->getPlugin('EconomyShop'); - }else{ - $this->economyshop = false; - } - - $this->initializeMenu(); - } - - /** - * @return Main - */ - public static function getInstance() : Main{ - return self::$instance; - } - - public function initializeMenu() : void{ - if(!class_exists(InvMenu::class)){ - $this->getLogger()->warning($this->getName()." uses the virion 'InvMenu' which doesn't exist. Please use the pre-built .phar file from poggit. If you would still like to continue running ChestShop from source, install DEVirion plugin and InvMenu virion and place InvMenu in the /virions folder."); - $this->getServer()->getPluginManager()->disablePlugin($this); - return; - } - - if(!InvMenuHandler::isRegistered()){ - InvMenuHandler::register($this); - } - - $this->menu = InvMenu::create(InvMenu::TYPE_CHEST) - ->readonly() - ->sessionize() - ->setName("Chest Shop") - ->setListener([new ShopListener($this), "onTransaction"]); - } - - public function onDisable() : void{ - yaml_emit_file($this->getDataFolder().'shops.yml', $this->shops); - } - - /** - * Updates config with newer data. - */ - private function updateConfig(string $config) : void{ - $path = $this->getDataFolder().$config; - - if(!is_file($path)){ - if(!$this->saveResource($config)){ - throw new \Error("Tried updating a non-existing config file ({$config})."); - } - return; - } - - $data = yaml_parse_file($path); - - $resource = $this->getResource($config); - $default_config = yaml_parse(stream_get_contents($resource)); - fclose($resource); - - if(!empty($default_config)){ - $changed = false; - - foreach($default_config as $key => $value){ - if(!isset($data[$key])){ - $data[$key] = $value; - $changed = true; - } - } - - if($changed){ - yaml_emit_file($path, $data); - } - } - } - - /** - * Sends the chest shop (inventory) - * to the player. - * - * @param Player $player - */ - public function sendChestShop(Player $player) : void{ - $this->fillInventoryWithShop($this->menu->getInventory($player));//this is a big NO tbh, uh - $this->menu->send($player); - } - - public function reload() : void{ - $this->onDisable(); - $this->shops = []; - $shops = yaml_parse_file($this->getDataFolder().'shops.yml'); - if(!empty($shops)){ - $this->shops = $shops; - } - $config = yaml_parse_file($this->getDataFolder().'config.yml'); - $this->loadBannedItemsList($config["banned-items"]); - } - - public function loadBannedItemsList(array $bannedItems) : void{ - $corruptedItems = []; - - foreach($bannedItems as $itemdata){ - $itemdata = explode(":", $itemdata, 2); - - if(!isset($itemdata[1])){ - $itemdata[1] = 0; - } - - if(is_numeric($itemdata[0]) && is_numeric($itemdata[1])){ - $itemId = (int) $itemdata[0]; - $itemDamage = (int) $itemdata[1]; - $this->notallowed[($itemId << 16) | $itemDamage] = 1; - }else{ - $corruptedItems[] = implode(":", $itemdata); - } - } - - if(!empty($corruptedItems)){ - $this->getLogger()->warning("Some items could not be added to the ban list due to incorrect formatting ({implode(", ", $corruptedItems)})"); - } - } - - /** - * Get item from shop via shop ID. - * - * @param int $id - * @return Item - */ - public function getItemFromShop(int $id) : Item{ - if(isset($this->shops[$id])){ - $data = $this->shops[$id]; - return Item::get($data[0], $data[1], $data[2])->setNamedTag(unserialize($data[3])); - } - return Item::get(Item::AIR, 0, 0); - } - - /** - * Fills the $inventory with contents - * of chest shop. - * - * @param ChestInventory $inventory - * @param int $page - */ - public function fillInventoryWithShop(Inventory $inventory, int $page = 0) : void{ - $contents = []; - - if(!empty($this->shops)) { - $chunked = array_chunk($this->shops, 24, true); - if($page < 0){ - $page = count($chunked) - 1; - } - $page = isset($chunked[$page]) ? $page : 0; - foreach($chunked[$page] as $data){ - $item = Item::get($data[0], $data[1], $data[2]); - if($data[3] === null){ - break; - } - - $item->setNamedTag($nbt = unserialize($data[3])); - $item->setCustomName(TF::RESET.$item->getName()."\n \n".TF::YELLOW.'Tap to purchase for '.TF::BOLD.'$'.$nbt->getIntArray('ChestShop')[0].TF::RESET); - $contents[] = $item; - } - } - - // Page turners. - $turnleft = Item::get(Item::PAPER); - $turnleft->setCustomName(TF::RESET.TF::GOLD.TF::BOLD.'<< Turn Left'.TF::RESET."\n".TF::GRAY.'Turn towards the left.'); - $turnleft->setNamedTagEntry(new IntArrayTag('turner', [self::LEFT_TURNER, $page])); - $contents[25] = $turnleft; - - $turnright = Item::get(Item::PAPER); - $turnright->setCustomName(TF::RESET.TF::GOLD.TF::BOLD.'Turn Right >>'.TF::RESET."\n".TF::GRAY.'Turn towards the right.'); - $turnright->setNamedTagEntry(new IntArrayTag('turner', [self::RIGHT_TURNER, $page])); - $contents[26] = $turnright; - - $inventory->setContents($contents); - } - - /** - * Adds an item to the chest shop. - * - * @param Item $item - * @param int $price - */ - public function addToChestShop(Item $item, int $price) : void{ - while(isset($this->shops[$key = rand()])); - $item->setNamedTagEntry(new IntArrayTag('ChestShop', [$price, $key])); - $this->shops[$key] = [$item->getId(), $item->getDamage(), $item->getCount(), serialize($item->getNamedTag())]; - } - - /** - * Removes an item off the chest shop. - * - * @param int $page - * @param int $slot - */ - public function removeItemOffShop(int $page, int $slot) : void{ - if(empty($this->shops)){ - return; - } - $keys = array_keys($this->shops);//$this->shops is an associative array. - $key = 24 * $page + $slot;//array_chunks divides $shops into 24 parts in the GUI. - unset($this->shops[$keys[--$key]]);//$slot - 1. Slots are counted from 0. If $slot is 1, the issuer probably (actually) is referring to slot zero. - } - - /** - * Checks whether or not it's allowed - * to put an item in the chest shop. - * This can be set up in the config - * (banned-items key in the config). - * - * @param int $itemId - * @param int $itemDamage - * @return bool - */ - private function isNotAllowed(int $itemId, int $itemDamage = 0) : bool{ - return isset($this->notallowed[($itemId << 16) | $itemDamage]); - } - - /** - * Removes item off chest shop - * by key. - * - * @param int $keys - */ - public function removeItemsByKey(int ...$keys) : void{ - foreach($keys as $key){ - unset($this->shops[$key]); - } - } - - public function onCommand(CommandSender $sender, Command $cmd, $label, array $args) : bool{ - if(isset($args[0])){ - switch(strtolower($args[0])){ - case "help": - $sender->sendMessage(str_replace('{cmd}', $cmd, implode("\n", self::HELP_CMD))); - break; - case "about": - $sender->sendMessage(TF::YELLOW.TF::BOLD.'ChestShop'.TF::RESET."\n".TF::GRAY.'Created by Muqsit ('.TF::AQUA.'@muqsitrayyan'.TF::GRAY.').'); - break; - case "add": - if($sender->hasPermission('chestshop.command.add')){ - $item = $sender->getInventory()->getItemInHand(); - if($item->isNull()){ - $sender->sendMessage(self::PREFIX.TF::RED.'Please hold an item in your hand.'); - }elseif($this->isNotAllowed($item->getId(), $item->getDamage())){ - $sender->sendMessage(self::PREFIX.TF::RED.'You cannot sell '.((Item::get($item->getId(), $item->getDamage()))->getName()).' on /chestshop.'); - }else{ - if(isset($args[1]) && is_numeric($args[1]) && $args[1] >= 0) { - $sender->sendMessage(self::PREFIX.TF::YELLOW.'Added '.(explode("\n", $item->getName())[0]).' to '.$cmd->getName().' for $'.$args[1].'.'); - $this->addToChestShop($item, $args[1]); - }else{ - $sender->sendMessage(TF::RED.'Please enter a valid number.'); - } - } - } - break; - case "removebyid": - if($sender->hasPermission('chestshop.command.remove')){ - if(isset($args[1]) && is_numeric($args[1]) && $args[1] >= 1){ - $damage = $args[2] ?? 0; - if(count($this->shops) <= 27){ - $i = 0; - foreach($this->shops as $k => $item){ - if($item[0] == $args[1] && $item[1] == $damage){ - unset($this->shops[$k]); - ++$i; - } - } - $sender->sendMessage(self::PREFIX.TF::YELLOW.$i.' items were removed off auction house (ID: '.$args[1].', DAMAGE: '.$damage.').'); - }else{ - $this->getServer()->getScheduler()->scheduleAsyncTask(new RemoveByIdTask([$sender->getName(), $args[1], $damage, &$this->shops])); - } - }else{ - $sender->sendMessage(self::PREFIX.TF::YELLOW.'Usage: /'.$cmd->getName().' removebyid [item-id] [item-damage]'); - } - } - break; - case "remove": - if($sender->hasPermission('chestshop.command.remove')){ - if(isset($args[1], $args[2]) && is_numeric($args[1]) && is_numeric($args[2]) && ($args[1] >= 0) && ($args[2] >= 1)){ - $sender->sendMessage(self::PREFIX.TF::YELLOW.'Removed item on page #'.$args[1].', slot #'.$args[2].'.'); - $this->removeItemOffShop($args[1], $args[2]); - }else{ - $sender->sendMessage(TF::RED.'Page number and item slot must be integers (page > -1, slot > 0).'); - } - } - break; - case "reload": - if($sender->hasPermission('chestshop.command.reload')){ - $sender->sendMessage(self::PREFIX.TF::AQUA.'ChestShop is reloading...'); - $this->reload(); - $sender->sendMessage(self::PREFIX.TF::AQUA.'ChestShop has reloaded successfully.'); - } - break; - case "synceconomy": - if($sender->hasPermission('chestshop.command.opcmd')){ - switch($this->economyshop){ - case null: - $sender->sendMessage(TF::RED."Couldn't find EconomyShop plugin. Make sure the plugin is enabled and running."); - return false; - case false: - $sender->sendMessage(TF::RED.'You must set the "enable-sync" option to true in the config to use this command.'); - return false; - } - $provider = new \onebone\economyshop\provider\YAMLDataProvider($this->economyshop->getDataFolder().'Shops.yml', false);//read-only - - $this->getLogger()->info($sender->getName().' is synchronizing data from EconomyShop.'); - $time = microtime(true); - - foreach($provider->getAll() as $shop){ - if (is_string($shop["item"] ?? $shop[4])){ - $itemId = ItemFactory::fromString((string) ($shop["item"] ?? $shop[4]), false)->getId(); - }else{ - $itemId = ItemFactory::get((int) ($shop["item"] ?? $shop[4]), false)->getId(); - } - $item = ItemFactory::get($itemId, (int) ($shop["meta"] ?? $shop[5]), (int) ($shop["amount"] ?? $shop[7])); - - $this->addToChestShop($item, $shop["price"] ?? $shop[8]); - } - - $time = microtime(true) - $time; - $sender->sendMessage(TF::YELLOW.'Data synchronized. Took '.TF::GREEN.$time.TF::YELLOW.'s.'); - $this->getLogger()->info($sender->getName().' has synchronized data from EconomyShop (Took '.$time.' seconds).'); - } - break; - default: - $sender->sendMessage(TF::RED.'Type /cs help to get a list of chest shop help commands.'); - break; - } - }else{ - if($sender instanceof Player){ - $this->sendChestShop($sender); - }else{ - $sender->sendMessage(str_replace('{cmd}', $cmd, implode("\n", self::HELP_CMD))); - } - } - return true; - } -} diff --git a/src/ChestShop/RemoveByIdTask.php b/src/ChestShop/RemoveByIdTask.php deleted file mode 100644 index c288a02..0000000 --- a/src/ChestShop/RemoveByIdTask.php +++ /dev/null @@ -1,43 +0,0 @@ -data = $data; - } - - public function onRun() : void{ - $res = []; - foreach($this->data[3] as $k => $v){ - if($v[0] == $this->data[1] && $v[1] == $this->data[2]){ - $res[] = $k; - } - } - $this->setResult($res); - } - - public function onCompletion(Server $server) : void{ - $res = $this->getResult(); - if(($player = $server->getPlayerExact($this->data[0])) instanceof Player){ - $player->sendMessage(Main::PREFIX.TF::YELLOW.count($res).' items were removed off auction house (ID: '.$this->data[1].', DAMAGE: '.$this->data[2].').'); - } - $server->getPluginManager()->getPlugin("ChestShop")->removeItemsByKey(...$res); - } -} \ No newline at end of file diff --git a/src/ChestShop/ShopListener.php b/src/ChestShop/ShopListener.php deleted file mode 100644 index a93b094..0000000 --- a/src/ChestShop/ShopListener.php +++ /dev/null @@ -1,86 +0,0 @@ -plugin = $plugin; - $this->economy = EconomyAPI::getInstance(); - } - - /** - * This is where all the chest shop - * transaction are handled. - */ - public function onTransaction(Player $player, Item $itemPuttingIn, Item $itemTakingOut, SlotChangeAction $inventoryAction) : bool{ - $itemTakingOut = $inventoryAction->getSourceItem();//item in the chest inventory when clicked. - - $nbt = $itemTakingOut->getNamedTag(); - - if($nbt->hasTag("turner")){ - $pagedata = $nbt->getIntArray("turner"); - $page = $pagedata[Main::NBT_TURNER_DIRECTION] === Main::LEFT_TURNER ? --$pagedata[Main::NBT_TURNER_CURRENTPAGE] : ++$pagedata[Main::NBT_TURNER_CURRENTPAGE]; - $this->plugin->fillInventoryWithShop($inventoryAction->getInventory(), $page); - return true; - } - - if($nbt->hasTag("ChestShop")){ - $cs = $nbt->getIntArray("ChestShop"); - - $price = $cs[Main::NBT_CHESTSHOP_PRICE]; - if($this->economy->myMoney($player) >= $price){ - $item = $this->plugin->getItemFromShop($cs[Main::NBT_CHESTSHOP_ID]); - $player->sendMessage(Main::PREFIX.TF::GREEN.'Purchased '.TF::BOLD.$item->getName().TF::RESET.TF::GREEN.TF::GRAY.' (x'.$item->getCount().')'.TF::GREEN.' for $'.$price.'.'); - $player->getInventory()->addItem($item); - $this->economy->reduceMoney($player, $price); - - $pk = new LevelEventPacket(); - $pk->evid = LevelEventPacket::EVENT_SOUND_ORB; - $pk->data = PHP_INT_MAX; - $pk->position = $player->asVector3(); - $player->dataPacket($pk); - }else{ - $player->sendMessage(Main::PREFIX.TF::RED.'You cannot afford this item.'); - $inventoryAction->getInventory()->onClose($player); - } - } - - return true; - } -} diff --git a/src/muqsit/chestshop/Button.php b/src/muqsit/chestshop/Button.php new file mode 100644 index 0000000..8270d55 --- /dev/null +++ b/src/muqsit/chestshop/Button.php @@ -0,0 +1,100 @@ + [ + "id" => Item::PAPER, + "damage" => 0, + "count" => 1, + "name" => TF::RESET.TF::GOLD.TF::BOLD."<- Turn Left", + "lore" => [ + TF::RESET.TF::GRAY."Turn to left page." + ] + ], + "turn_right" => [ + "id" => Item::PAPER, + "damage" => 0, + "count" => 1, + "name" => TF::RESET.TF::GOLD.TF::BOLD."Turn Right ->", + "lore" => [ + TF::RESET.TF::GRAY."Turn to right page." + ] + ], + "categories" => [ + "id" => Item::CHEST, + "damage" => 0, + "count" => 1, + "name" => TF::RESET.TF::YELLOW.TF::BOLD."View Categories", + "lore" => [ + TF::RESET.TF::GRAY."Back to categories." + ] + ], + ]; + + public static function setOptions(array $options) : void{ + self::$options = $options; + array_walk_recursive(self::$options, function(&$value) : void{ + if(is_string($value)){ + $value = TF::colorize($value); + } + }); + } + + public static function getOptions() : array{ + $options = self::$options; + array_walk_recursive($options, function(&$value) : void{ + if(is_string($value)){ + $value = str_replace(TF::ESCAPE, "&", $value); + } + }); + + return $options; + } + + public static function get(int $turn, ...$args) : Item{ + switch($turn){ + case Button::TURN_LEFT: + $item = Button::itemFromData(self::$options["turn_left"]); + $item->setNamedTagEntry(new ByteTag("Button", Button::TURN_LEFT)); + $item->setNamedTagEntry(new StringTag("Category", $args[0])); + return $item; + case Button::TURN_RIGHT: + $item = Button::itemFromData(self::$options["turn_right"]); + $item->setNamedTagEntry(new ByteTag("Button", Button::TURN_RIGHT)); + $item->setNamedTagEntry(new StringTag("Category", $args[0])); + return $item; + case Button::CATEGORIES: + $item = Button::itemFromData(self::$options["categories"]); + $item->setNamedTagEntry(new ByteTag("Button", Button::CATEGORIES)); + return $item; + } + + throw new \InvalidArgumentException("Invalid button type '$turn'"); + } + + public static function itemFromData(array $data) : Item{ + [ + "id" => $id, + "damage" => $damage, + "count" => $count, + "name" => $name, + "lore" => $lore + ] = $data; + + $item = Item::get($id, $damage, $count); + $item->setCustomName($name); + $item->setLore($lore); + + return $item; + } +} \ No newline at end of file diff --git a/src/muqsit/chestshop/Category.php b/src/muqsit/chestshop/Category.php new file mode 100644 index 0000000..e97ead7 --- /dev/null +++ b/src/muqsit/chestshop/Category.php @@ -0,0 +1,132 @@ +name = $name; + $this->identifier = $identifier; + + $this->menu = InvMenu::create(InvMenu::TYPE_DOUBLE_CHEST); + $this->menu->getInventory()->setContents([ + 48 => Button::get(Button::TURN_LEFT, $this->getRealName()), + 49 => Button::get(Button::CATEGORIES), + 50 => Button::get(Button::TURN_RIGHT, $this->getRealName()) + ]); + + $this->menu + ->readonly() + ->sessionize() + ->setName($name) + ->setListener([$plugin->getEventHandler(), "handleTransaction"]) + ->setInventoryCloseListener([$plugin->getEventHandler(), "handlePageCacheRemoval"]); + } + + public function getRealName() : string{ + return TF::clean($this->name); + } + + public function getName() : string{ + return $this->name; + } + + public function getIdentifier() : Item{ + $item = clone $this->identifier; + $item->setCustomName(TF::RESET.TF::BOLD.TF::AQUA.$this->name); + $item->setLore([TF::RESET.TF::GRAY."Click to view this category."]); + $item->setNamedTagEntry(new StringTag("Category", $this->getRealName())); + return $item; + } + + public function getContents(int $page = 1) : array{ + //45 = 54 - 9. 54 is the number of slots of a double chest GUI + //We are removing the last 9 slots to make space for the "Turn Left" + //and "Turn Right" buttons. + + $page = min(ceil(count($this->items) / 45), max(1, $page));//page > 0 and page <= number of pages. + $contents = array_slice($this->items, ($page - 1) * 45, 45);//get the 45 items on this page. + $inventory = $this->menu->getInventory($player); + } + + public function addItem(Item $item, float $cost) : void{ + $item->setNamedTagEntry(new FloatTag("ChestShop", $cost)); + + $lore = $item->getLore(); + $lore[] = TF::RESET.TF::YELLOW.TF::BOLD."COST: \$".TF::RESET.TF::GOLD.sprintf("%.2f", $cost); + $item->setLore($lore); + + $this->items[] = $item; + } + + final private function setContents(array $items) : void{ + $this->items = $items; + } + + public function send(Player $player, int $page = 1, bool $send = true) : int{ + if(!empty($this->items)){ + + //45 = 54 - 9. 54 is the number of slots of a double chest GUI + //We are removing the last 9 slots to make space for the "Turn Left" + //and "Turn Right" buttons. + + $page = min(ceil(count($this->items) / 45), max(1, $page));//page > 0 and page <= number of pages. + $contents = array_slice($this->items, ($page - 1) * 45, 45);//get the 45 items on this page. + $inventory = $this->menu->getInventory($player); + + foreach($contents as $slot => $item){ + $inventory->setItem($slot, $item, false); + } + } + + if($send){ + $this->menu->send($player); + } + + $this->menu->getInventory($player)->sendContents($player); + return $page; + } + + public function nbtSerialize() : CompoundTag{ + $tag = new CompoundTag(); + $tag->setString("name", $this->name); + $tag->setTag($this->identifier->nbtSerialize(-1, "identifier")); + + $items = []; + foreach($this->items as $item){ + $items[] = $item->nbtSerialize(); + } + + $tag->setTag(new ListTag("items", $items)); + return $tag; + } + + public static function nbtDeserialize(ChestShop $plugin, CompoundTag $tag) : Category{ + $name = $tag->getString("name"); + $identifier = Item::nbtDeserialize($tag->getCompoundTag("identifier")); + + $items = []; + foreach($tag->getListTag("items") as $itemNBT){ + $items[] = Item::nbtDeserialize($itemNBT); + } + + $category = new Category($plugin, $name, $identifier); + $category->setContents($items); + return $category; + } +} \ No newline at end of file diff --git a/src/muqsit/chestshop/ChestShop.php b/src/muqsit/chestshop/ChestShop.php new file mode 100644 index 0000000..3022781 --- /dev/null +++ b/src/muqsit/chestshop/ChestShop.php @@ -0,0 +1,264 @@ +getDataFolder())){ + mkdir($this->getDataFolder()); + } + + $this->saveResource("config.yml"); + $this->saveResource("buttons.yml"); + + $config = $this->getConfig(); + + $this->eventHandler = new EventHandler($this, $config->get("double-tapping", false)); + $this->buttonsConfig = new Config($this->getDataFolder()."buttons.yml"); + + $buttons = $this->getButtonsConfig()->get("buttons"); + if(is_array($buttons)){ + Button::setOptions($buttons); + } + + if(!InvMenuHandler::isRegistered()){ + InvMenuHandler::register($this); + } + + $this->menu = InvMenu::create(InvMenu::TYPE_CHEST); + $this->menu + ->readonly() + ->setName("Choose A Category...") + ->setListener([$this->eventHandler, "handleCategoryChoosing"]) + ->setInventoryCloseListener([$this->eventHandler, "handlePageCacheRemoval"]); + + try{ + $this->loadShops(); + }catch(\Throwable $t){ + $this->crashed = true;//don't know if this is the best way to avoid data loss + throw $t; + } + } + + public function onDisable() : void{ + if(!$this->crashed){ + $this->saveShops(); + $this->getButtonsConfig()->set("buttons", Button::getOptions()); + $this->getButtonsConfig()->save(); + } + } + + private function getButtonsConfig() : Config{ + return $this->buttonsConfig; + } + + private function loadShops() : void{ + $file = $this->getDataFolder()."shops.dat"; + + if(is_file($file)){ + $raw = file_get_contents($file); + if(!empty($raw)){ + $cats = (new BigEndianNBTStream())->readCompressed($raw)->getListTag("Categories"); + foreach($cats as $tag){ + $this->setCategory(Category::nbtDeserialize($this, $tag)); + } + } + } + } + + private function saveShops() : void{ + $tag = new ListTag("Categories"); + + foreach($this->categories as $category){ + $tag->push($category->nbtSerialize()); + } + + file_put_contents($this->getDataFolder()."shops.dat", (new BigEndianNBTStream())->writeCompressed(new CompoundTag("", [$tag]))); + } + + public function getEventHandler() : EventHandler{ + return $this->eventHandler; + } + + public function addCategory(string $name, Item $identifier) : bool{ + if(isset($this->categories[strtolower(TF::clean($name))])){ + return false; + } + + $this->setCategory(new Category($this, $name, $identifier)); + return true; + } + + private function setCategory(Category $category) : void{ + $this->categories[strtolower($category->getRealName())] = $category; + $this->menu->getInventory()->addItem($category->getIdentifier()); + } + + public function getCategory(string $category) : ?Category{ + return $this->categories[strtolower($category)] ?? null; + } + + public function removeCategory(string $name) : bool{ + $category = $this->getCategory($name); + if($category === null){ + return false; + } + + unset($this->categories[strtolower(TF::clean($name))]); + $this->menu->getInventory()->removeItem($category->getIdentifier()); + return true; + } + + public function send(Player $player, int $delay = 0) : void{ + if($delay > 0){ + $this->getServer()->getScheduler()->scheduleDelayedTask(new DelayedInvMenuSendTask($this, $player, $this->menu), $delay); + return; + } + + $this->menu->send($player); + } + + public function sendCategory(Player $player, string $category, int $page = 1, bool $send = true){ + $category = $this->getCategory($category); + if($category === null){ + return false; + } + + return $category->send($player, $page, $send); + return true; + } + + public static function toOriginalItem(Item $item) : void{ + $item->removeNamedTagEntry("ChestShop"); + + $lore = $item->getLore(); + array_pop($lore); + $item->setLore($lore); + } + + public function onCommand(CommandSender $sender, Command $cmd, string $label, array $args) : bool{ + if(empty($args)){ + $this->menu->send($sender); + return true; + } + + switch($args[0]){ + case "addcat": + case "addcategory": + if($sender->hasPermission("chestshop.command.admin")){ + if(!isset($args[1])){ + $sender->sendMessage(TF::RED."/cs addcategory "); + return false; + } + + $item = $sender->getInventory()->getItemInHand(); + if($item->isNull()){ + $sender->sendMessage(TF::RED."Please hold an item in your hand. That item will be used as a button in the /{$label} GUI."); + return false; + } + + if(!$this->addCategory($args[1], $item)){ + $sender->sendMessage(TF::RED."A category named ".TF::clean($args[1])." already exists, please choose a new name."); + return false; + } + + $sender->sendMessage(TF::GREEN."Successfully created category {$args[1]}, use /cs to view it."); + return true; + } + break; + case "removecat": + case "removecategory": + if($sender->hasPermission("chestshop.command.admin")){ + if(!isset($args[1])){ + $sender->sendMessage(TF::RED."/cs removecategory "); + return false; + } + + if(!$this->removeCategory($args[1])){ + $sender->sendMessage(TF::RED."No category named ".TF::clean($args[1])." could be found."); + return false; + } + + $sender->sendMessage(TF::GREEN."Successfully removed category {$args[1]}."); + return true; + } + break; + case "categories": + if($sender->hasPermission("chestshop.command.admin")){ + foreach($this->categories as $category){ + $sender->sendMessage($category->getName()); + } + return true; + } + break; + case "additem": + if($sender->hasPermission("chestshop.command.admin")){ + if(!isset($args[1])){ + $sender->sendMessage(TF::RED."/cs additem "); + return false; + } + + $category = $this->getCategory($args[1]); + if($category === null){ + $sender->sendMessage(TF::RED."No category named ".TF::clean($args[1])." could be found."); + return false; + } + + $item = $sender->getInventory()->getItemInHand(); + if($item->isNull()){ + $sender->sendMessage(TF::RED."Please hold an item in your hand."); + return false; + } + + if(isset($args[2]) && is_numeric($args[2]) && $args[2] >= 0) { + $category->addItem($item, $args[2]); + $sender->sendMessage(TF::YELLOW."Added ".$item->getName()." to category '".$category->getName().TF::RESET.TF::YELLOW."' for \${$args[2]}."); + return true; + } + + $sender->sendMessage(TF::RED."Please enter a valid number."); + return false; + } + break; + } + + if($sender->hasPermission("chestshop.command.admin")){ + $sender->sendMessage( + TF::YELLOW.TF::BOLD."ChestShop v".$this->getDescription()->getVersion().TF::RESET."\n". + TF::GOLD."/".$label." ".TF::GRAY."addcat/addcategory - Add a category named in /".$label."\n". + TF::GOLD."/".$label." ".TF::GRAY."removecat/removecategory - Remove category from /".$label."\n". + TF::GOLD."/".$label." ".TF::GRAY."categories - List all categories\n". + TF::GOLD."/".$label." ".TF::GRAY."additem - Add held item to for " + ); + return true; + } + + return false; + } +} diff --git a/src/muqsit/chestshop/EventHandler.php b/src/muqsit/chestshop/EventHandler.php new file mode 100644 index 0000000..0e5c311 --- /dev/null +++ b/src/muqsit/chestshop/EventHandler.php @@ -0,0 +1,121 @@ +plugin = $plugin; + $this->economy = EconomyAPI::getInstance(); + + if($enableDoubleClicks){ + $this->doubleclicks = []; + } + $plugin->getServer()->getPluginManager()->registerEvents($this, $plugin); + } + + public function handlePlayerQuit(PlayerQuitEvent $event) : void{ + $playerId = $event->getPlayer()->getId(); + unset($this->doubleclicks[$playerId], $this->currentpage[$playerId]); + } + + public function handlePageCacheRemoval(Player $player) : bool{ + unset($this->currentpage[$player->getId()]); + return true; + } + + public function handleTransaction(Player $player, Item $itemClicked, Item $itemClickedUsing, SlotChangeAction $inventoryAction) : bool{ + $nbt = $itemClicked->getNamedTag(); + + if($nbt->hasTag("Button")){ + $currentpage = $this->currentpage[$playerId = $player->getId()] ?? 1; + switch($nbt->getByte("Button")){ + case Button::TURN_LEFT: + $this->currentpage[$playerId] = $this->plugin->sendCategory($player, $category = $nbt->getString("Category"), ++$currentpage, false); + if($this->currentpage[$playerId] === false){ + $player->removeWindow($inventoryAction->getInventory()); + $player->sendMessage(TF::RED."Could not find category '".$category."', perhaps it has been removed."); + } + break; + case Button::TURN_RIGHT: + $this->currentpage[$playerId] = $this->plugin->sendCategory($player, $category = $nbt->getString("Category"), ++$currentpage, false); + if($this->currentpage[$playerId] === false){ + $player->removeWindow($inventoryAction->getInventory()); + $player->sendMessage(TF::RED."Could not find category '".$category."', perhaps it has been removed."); + } + break; + case Button::CATEGORIES: + $player->removeWindow($inventoryAction->getInventory()); + $this->plugin->send($player, 5); + break; + } + return true; + } + + if($nbt->hasTag("ChestShop")){ + $cost = $nbt->getFloat("ChestShop"); + + if($this->economy->myMoney($player) < $cost){ + $player->sendMessage(TF::RED."You do not have enough money to purchase ".$itemClicked->getName()."."); + $player->removeWindow($inventoryAction->getInventory()); + return true; + } + + if($this->doubleclicks !== null){ + if( + !isset($this->doubleclicks[$playerId = $player->getId()]) || + $player->ticksLived - $this->doubleclicks[$playerId] > 15//15 tick delay for double taps, probably make this a configurable number + ){ + //maybe add a popup or a message here regarding double tapping? + $this->doubleclicks[$playerId] = $player->ticksLived; + return true; + } + unset($this->doubleclicks[$playerId]); + } + + ChestShop::toOriginalItem($itemClicked); + + $level = $player->getLevel(); + foreach($player->getInventory()->addItem($itemClicked) as $item){ + $level->dropItem($player, $item); + } + + $player->sendMessage(TF::YELLOW."Purchased ".$itemClicked->getName()." (x".$itemClicked->getCount().")".TF::RESET.TF::YELLOW." for \$".TF::RESET.TF::GOLD.$cost); + $this->economy->reduceMoney($player, $cost); + } + + return true; + } + + public function handleCategoryChoosing(Player $player, Item $itemClicked, Item $itemClickedUsing, SlotChangeAction $inventoryAction) : bool{ + $nbt = $itemClicked->getNamedTag(); + + if($nbt->hasTag("Category")){ + $player->removeWindow($inventoryAction->getInventory()); + if(!$this->plugin->sendCategory($player, $category = $nbt->getString("Category"))){ + $player->sendMessage(TF::RED."Could not find category '".$category."', perhaps it has been removed."); + } + } + return true; + } +} \ No newline at end of file diff --git a/src/muqsit/chestshop/tasks/DelayedInvMenuSendTask.php b/src/muqsit/chestshop/tasks/DelayedInvMenuSendTask.php new file mode 100644 index 0000000..e871373 --- /dev/null +++ b/src/muqsit/chestshop/tasks/DelayedInvMenuSendTask.php @@ -0,0 +1,29 @@ +player = $player; + $this->menu = $menu; + } + + public function onRun(int $tick) : void{ + if($this->player->isAlive() && $this->player->isConnected()){ + $this->menu->send($this->player); + } + } +} \ No newline at end of file