├── gradle.properties ├── src └── main │ ├── resources │ ├── altar.nbt │ ├── paper-plugin.yml │ └── config.yml │ └── kotlin │ └── ru │ └── teasanctuary │ └── hardcore_experiment │ ├── config │ ├── ConfigField.kt │ ├── ConfigFieldArgumentType.kt │ └── HardcoreExperimentConfig.kt │ ├── types │ ├── PlayerStateChangeRequest.kt │ ├── PlayerState.kt │ ├── DeadPlayerStatus.kt │ ├── WorldEpochDataType.kt │ ├── PlayerStateDataType.kt │ ├── PlayerStateArgumentType.kt │ ├── WorldEpochArgumentType.kt │ ├── DeadPlayersListDataType.kt │ ├── StateChangeQueueDataType.kt │ ├── WorldEpoch.kt │ └── AltarSchematic.kt │ ├── listener │ ├── WorldEventListener.kt │ ├── JoinEventListener.kt │ ├── TotemEventListener.kt │ ├── EpochEventListener.kt │ └── MortisEventListener.kt │ └── HardcoreExperiment.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── settings.gradle.kts ├── .gitignore ├── gradlew.bat └── gradlew /gradle.properties: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/altar.nbt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rndtrash/hardcore-experiment/master/src/main/resources/altar.nbt -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rndtrash/hardcore-experiment/master/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 3 | } 4 | 5 | rootProject.name = "hardcore_experiment" 6 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/config/ConfigField.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.config 2 | 3 | @Target(AnnotationTarget.PROPERTY) 4 | annotation class ConfigField 5 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/PlayerStateChangeRequest.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.Location 4 | 5 | data class PlayerStateChangeRequest(val state: PlayerState, val location: Location) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/resources/paper-plugin.yml: -------------------------------------------------------------------------------- 1 | name: HardcoreExperiment 2 | version: '1.0-SNAPSHOT' 3 | main: ru.teasanctuary.hardcore_experiment.HardcoreExperiment 4 | api-version: '1.21' 5 | prefix: HE 6 | authors: [ rndtrash ] 7 | description: A new way to experience the hardcore Minecraft. 8 | website: https://teasanctuary.ru 9 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/PlayerState.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | enum class PlayerState { 4 | /** 5 | * Начальное состояние. Игрок живой. 6 | */ 7 | Alive, 8 | 9 | /** 10 | * Игрок умер, даётся время на возрождение, режим наблюдателя ограничен местом смерти. 11 | */ 12 | LimitedSpectator, 13 | 14 | /** 15 | * Игрок умер окончательно, снимается ограничение на перемещение. 16 | */ 17 | Spectator 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/DeadPlayerStatus.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 4 | 5 | data class DeadPlayerStatus(val epochTimeStamp: Long, val epoch: WorldEpoch, val deadline: Long) { 6 | fun getCost() = epoch.getRespawnCost(epochTimeStamp) 7 | 8 | /** 9 | * Время в тиках до истечения возможности возродиться. 10 | * 11 | * Возвращает отрицательное значение, если возможность упущена. ¯\_(ツ)_/¯ 12 | */ 13 | fun getTimeLeft(plugin: HardcoreExperiment) = deadline - plugin.defaultWorld.gameTime 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/config.yml: -------------------------------------------------------------------------------- 1 | # TODO: перевести комментарии на английский. Не то, что я не знаю английского, да и не то, что кто-то из англичан вообще узнает об этом плагине, просто было бы приятно. 2 | 3 | hardcore-experiment: 4 | ==: ru.teasanctuary.hardcore_experiment.config.HardcoreExperimentConfig 5 | # Время, за которое эпоха угля автоматически переходит в эпоху меди 6 | # В секундах реального времени. 7 | coal-epoch-upgrade-timer: 180 8 | 9 | # Время, за которое игроки должны зайти в игру после создания мира, чтобы появиться не в качестве наблюдателя. 10 | # В секундах реального времени. 11 | initial-join-timer: 300 12 | 13 | # Время, которое даётся игрокам на возрождение товарища. 14 | # В секундах реального времени. 15 | respawn-timeout: 1200 -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/WorldEpochDataType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.persistence.PersistentDataAdapterContext 4 | import org.bukkit.persistence.PersistentDataType 5 | 6 | class WorldEpochDataType : PersistentDataType { 7 | override fun getPrimitiveType(): Class { 8 | // TODO: почему это не работает?????? 9 | // return Byte::class.java 10 | return PersistentDataType.BYTE.primitiveType 11 | } 12 | 13 | override fun getComplexType(): Class { 14 | return WorldEpoch::class.java 15 | } 16 | 17 | override fun fromPrimitive(primitive: Byte, context: PersistentDataAdapterContext): WorldEpoch { 18 | return WorldEpoch.entries[primitive.toInt()] 19 | } 20 | 21 | override fun toPrimitive(complex: WorldEpoch, context: PersistentDataAdapterContext): Byte { 22 | return complex.ordinal.toByte() 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/listener/WorldEventListener.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.listener 2 | 3 | import org.bukkit.event.EventHandler 4 | import org.bukkit.event.Listener 5 | import org.bukkit.event.world.WorldLoadEvent 6 | import org.bukkit.event.world.WorldSaveEvent 7 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 8 | import java.util.logging.Level 9 | 10 | class WorldEventListener(private val plugin: HardcoreExperiment) : Listener { 11 | // TODO: походу, этот вызов вообще никогда не срабатывает при нормальных обстоятельствах. 12 | @EventHandler 13 | fun onWorldLoad(event: WorldLoadEvent) { 14 | plugin.logger.log(Level.INFO, "World data has loaded!") 15 | } 16 | 17 | @EventHandler 18 | fun onWorldSave(event: WorldSaveEvent) { 19 | plugin.logger.log(Level.INFO, "World data has been saved!") 20 | 21 | if (event.world == plugin.defaultWorld) plugin.saveWorldStorage() 22 | } 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/PlayerStateDataType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.persistence.PersistentDataAdapterContext 4 | import org.bukkit.persistence.PersistentDataType 5 | 6 | class PlayerStateDataType : PersistentDataType { 7 | override fun getPrimitiveType(): Class { 8 | // TODO: почему это не работает?????? 9 | // return Byte::class.java 10 | return PersistentDataType.BYTE.primitiveType 11 | } 12 | 13 | override fun getComplexType(): Class { 14 | return PlayerState::class.java 15 | } 16 | 17 | override fun fromPrimitive(primitive: Byte, context: PersistentDataAdapterContext): PlayerState { 18 | return PlayerState.entries[primitive.toInt()] 19 | } 20 | 21 | override fun toPrimitive(complex: PlayerState, context: PersistentDataAdapterContext): Byte { 22 | return complex.ordinal.toByte() 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/PlayerStateArgumentType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import com.mojang.brigadier.arguments.ArgumentType 4 | import com.mojang.brigadier.arguments.StringArgumentType 5 | import com.mojang.brigadier.context.CommandContext 6 | import com.mojang.brigadier.suggestion.Suggestions 7 | import com.mojang.brigadier.suggestion.SuggestionsBuilder 8 | import io.papermc.paper.command.brigadier.argument.CustomArgumentType 9 | import java.util.concurrent.CompletableFuture 10 | 11 | class PlayerStateArgumentType : CustomArgumentType.Converted { 12 | override fun getNativeType(): ArgumentType { 13 | return StringArgumentType.word() 14 | } 15 | 16 | override fun convert(nativeType: String): PlayerState { 17 | return PlayerState.valueOf(nativeType) 18 | } 19 | 20 | override fun listSuggestions( 21 | context: CommandContext, 22 | builder: SuggestionsBuilder 23 | ): CompletableFuture { 24 | PlayerState.entries.forEach { 25 | builder.suggest(it.name) 26 | } 27 | 28 | return builder.buildFuture() 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/WorldEpochArgumentType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import com.mojang.brigadier.arguments.ArgumentType 4 | import com.mojang.brigadier.arguments.StringArgumentType 5 | import com.mojang.brigadier.context.CommandContext 6 | import com.mojang.brigadier.suggestion.Suggestions 7 | import com.mojang.brigadier.suggestion.SuggestionsBuilder 8 | import io.papermc.paper.command.brigadier.argument.CustomArgumentType 9 | import java.util.concurrent.CompletableFuture 10 | 11 | class WorldEpochArgumentType : CustomArgumentType.Converted { 12 | override fun getNativeType(): ArgumentType { 13 | return StringArgumentType.word() 14 | } 15 | 16 | override fun convert(nativeType: String): WorldEpoch { 17 | return WorldEpoch.valueOf(nativeType) 18 | } 19 | 20 | override fun listSuggestions( 21 | context: CommandContext, builder: SuggestionsBuilder 22 | ): CompletableFuture { 23 | // Опускаем самую первую "эпоху" Invalid 24 | WorldEpoch.entries.drop(1).forEach { 25 | builder.suggest(it.name) 26 | } 27 | 28 | return builder.buildFuture() 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/config/ConfigFieldArgumentType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.config 2 | 3 | import com.mojang.brigadier.arguments.ArgumentType 4 | import com.mojang.brigadier.arguments.StringArgumentType 5 | import com.mojang.brigadier.context.CommandContext 6 | import com.mojang.brigadier.suggestion.Suggestions 7 | import com.mojang.brigadier.suggestion.SuggestionsBuilder 8 | import io.papermc.paper.command.brigadier.argument.CustomArgumentType 9 | import java.util.concurrent.CompletableFuture 10 | import kotlin.reflect.full.findAnnotation 11 | 12 | class ConfigFieldArgumentType : CustomArgumentType.Converted { 13 | override fun getNativeType(): ArgumentType { 14 | return StringArgumentType.word() 15 | } 16 | 17 | override fun convert(nativeType: String): ConfigField { 18 | return HardcoreExperimentConfig.CONFIG_FIELDS.first { it.name == nativeType }.findAnnotation()!! 19 | } 20 | 21 | override fun listSuggestions( 22 | context: CommandContext, builder: SuggestionsBuilder 23 | ): CompletableFuture { 24 | HardcoreExperimentConfig.CONFIG_FIELDS.forEach { 25 | builder.suggest(it.name) 26 | } 27 | 28 | return builder.buildFuture() 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/listener/JoinEventListener.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.listener 2 | 3 | import org.bukkit.GameMode 4 | import org.bukkit.event.EventHandler 5 | import org.bukkit.event.Listener 6 | import org.bukkit.event.player.PlayerJoinEvent 7 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 8 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment.Companion.REAL_SECONDS_TO_GAME_TIME 9 | 10 | class JoinEventListener(private val plugin: HardcoreExperiment) : Listener { 11 | @EventHandler 12 | fun onPlayerJoin(event: PlayerJoinEvent) { 13 | val player = event.player 14 | plugin.sendCurrentEpoch(player) 15 | 16 | val state = plugin.getPlayerState(player) 17 | if (state == null) { 18 | // Позволяем игрокам попасть в игру, если не стоит ограничение по времени на присоединение. 19 | // Используем время игрового мира, чтобы вычесть время сервера, проведённое в отключенном состоянии. 20 | val canJoinGame = 21 | plugin.hardcoreConfig.initialJoinTimer <= 0 || player.world.gameTime <= plugin.hardcoreConfig.initialJoinTimer * REAL_SECONDS_TO_GAME_TIME 22 | if (player.gameMode != GameMode.SPECTATOR && canJoinGame) { 23 | plugin.makePlayerAlive(player, null) 24 | } else { 25 | plugin.makePlayerSpectate(player) 26 | } 27 | } 28 | 29 | plugin.processPlayerStateRequest(event.player) 30 | } 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/DeadPlayersListDataType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.persistence.PersistentDataAdapterContext 4 | import org.bukkit.persistence.PersistentDataType 5 | import java.nio.ByteBuffer 6 | import java.util.* 7 | 8 | // TODO: Ну и хуета! ComplexType не хочет принимать Pair 9 | data class DeadPlayerPair(val player: UUID, val status: DeadPlayerStatus) 10 | 11 | class DeadPlayersListDataType : PersistentDataType { 12 | override fun getPrimitiveType(): Class { 13 | // TODO: почему это не работает?????? 14 | // return Byte::class.java 15 | return PersistentDataType.BYTE_ARRAY.primitiveType 16 | } 17 | 18 | override fun getComplexType(): Class { 19 | return DeadPlayerPair::class.java 20 | } 21 | 22 | override fun fromPrimitive(primitive: ByteArray, context: PersistentDataAdapterContext): DeadPlayerPair { 23 | val bb = ByteBuffer.wrap(primitive) 24 | 25 | return DeadPlayerPair( 26 | UUID(bb.getLong(), bb.getLong()), 27 | DeadPlayerStatus(bb.getLong(), WorldEpochDataType().fromPrimitive(bb.get(), context), bb.getLong()) 28 | ) 29 | } 30 | 31 | override fun toPrimitive(complex: DeadPlayerPair, context: PersistentDataAdapterContext): ByteArray { 32 | val bb = ByteBuffer.wrap(ByteArray(16 + 8 + 1 + 8)) 33 | 34 | bb.putLong(complex.player.mostSignificantBits) 35 | bb.putLong(complex.player.leastSignificantBits) 36 | 37 | bb.putLong(complex.status.epochTimeStamp) 38 | bb.put(WorldEpochDataType().toPrimitive(complex.status.epoch, context)) 39 | bb.putLong(complex.status.deadline) 40 | 41 | return bb.array() 42 | } 43 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff 2 | .idea/ 3 | 4 | *.iml 5 | *.ipr 6 | *.iws 7 | 8 | # IntelliJ 9 | out/ 10 | # mpeltonen/sbt-idea plugin 11 | .idea_modules/ 12 | 13 | # JIRA plugin 14 | atlassian-ide-plugin.xml 15 | 16 | # Compiled class file 17 | *.class 18 | 19 | # Log file 20 | *.log 21 | 22 | # BlueJ files 23 | *.ctxt 24 | 25 | # Package Files # 26 | *.jar 27 | *.war 28 | *.nar 29 | *.ear 30 | *.zip 31 | *.tar.gz 32 | *.rar 33 | 34 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 35 | hs_err_pid* 36 | 37 | *~ 38 | 39 | # temporary files which can be created if a process still has a handle open of a deleted file 40 | .fuse_hidden* 41 | 42 | # KDE directory preferences 43 | .directory 44 | 45 | # Linux trash folder which might appear on any partition or disk 46 | .Trash-* 47 | 48 | # .nfs files are created when an open file is removed but is still being accessed 49 | .nfs* 50 | 51 | # General 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | 56 | # Icon must end with two \r 57 | Icon 58 | 59 | # Thumbnails 60 | ._* 61 | 62 | # Files that might appear in the root of a volume 63 | .DocumentRevisions-V100 64 | .fseventsd 65 | .Spotlight-V100 66 | .TemporaryItems 67 | .Trashes 68 | .VolumeIcon.icns 69 | .com.apple.timemachine.donotpresent 70 | 71 | # Directories potentially created on remote AFP share 72 | .AppleDB 73 | .AppleDesktop 74 | Network Trash Folder 75 | Temporary Items 76 | .apdisk 77 | 78 | # Windows thumbnail cache files 79 | Thumbs.db 80 | Thumbs.db:encryptable 81 | ehthumbs.db 82 | ehthumbs_vista.db 83 | 84 | # Dump file 85 | *.stackdump 86 | 87 | # Folder config file 88 | [Dd]esktop.ini 89 | 90 | # Recycle Bin used on file shares 91 | $RECYCLE.BIN/ 92 | 93 | # Windows Installer files 94 | *.cab 95 | *.msi 96 | *.msix 97 | *.msm 98 | *.msp 99 | 100 | # Windows shortcuts 101 | *.lnk 102 | 103 | .gradle 104 | build/ 105 | 106 | # Ignore Gradle GUI config 107 | gradle-app.setting 108 | 109 | # Cache of project 110 | .gradletasknamecache 111 | 112 | **/build/ 113 | 114 | # Common working directory 115 | run/ 116 | 117 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 118 | !gradle-wrapper.jar 119 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/StateChangeQueueDataType.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.Bukkit 4 | import org.bukkit.Location 5 | import org.bukkit.persistence.PersistentDataAdapterContext 6 | import org.bukkit.persistence.PersistentDataType 7 | import java.nio.ByteBuffer 8 | import java.util.* 9 | 10 | // TODO: Ну и хуета! ComplexType не хочет принимать Pair 11 | data class StateChangePair(val player: UUID, val state: PlayerStateChangeRequest) 12 | 13 | class StateChangeQueueDataType : PersistentDataType { 14 | override fun getPrimitiveType(): Class { 15 | // TODO: почему это не работает?????? 16 | // return Byte::class.java 17 | return PersistentDataType.BYTE_ARRAY.primitiveType 18 | } 19 | 20 | override fun getComplexType(): Class { 21 | return StateChangePair::class.java 22 | } 23 | 24 | override fun fromPrimitive(primitive: ByteArray, context: PersistentDataAdapterContext): StateChangePair { 25 | val bb = ByteBuffer.wrap(primitive) 26 | 27 | return StateChangePair( 28 | UUID(bb.getLong(), bb.getLong()), PlayerStateChangeRequest( 29 | PlayerStateDataType().fromPrimitive(bb.get(), context), Location( 30 | Bukkit.getWorld(UUID(bb.getLong(), bb.getLong())), 31 | bb.getInt().toDouble(), 32 | bb.getInt().toDouble(), 33 | bb.getInt().toDouble() 34 | ) 35 | ) 36 | ) 37 | } 38 | 39 | override fun toPrimitive(complex: StateChangePair, context: PersistentDataAdapterContext): ByteArray { 40 | val bb = ByteBuffer.wrap(ByteArray(16 + 1 + 4 + 4 + 4 + 16)) 41 | 42 | bb.putLong(complex.player.mostSignificantBits) 43 | bb.putLong(complex.player.leastSignificantBits) 44 | 45 | bb.put(PlayerStateDataType().toPrimitive(complex.state.state, context)) 46 | 47 | val location = complex.state.location 48 | 49 | val worldUid = location.world.uid 50 | bb.putLong(worldUid.mostSignificantBits) 51 | bb.putLong(worldUid.leastSignificantBits) 52 | 53 | bb.putInt(location.blockX) 54 | bb.putInt(location.blockY) 55 | bb.putInt(location.blockZ) 56 | 57 | return bb.array() 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/listener/TotemEventListener.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.listener 2 | 3 | import net.kyori.adventure.text.minimessage.MiniMessage 4 | import org.bukkit.Material 5 | import org.bukkit.entity.EntityType 6 | import org.bukkit.entity.HumanEntity 7 | import org.bukkit.event.EventHandler 8 | import org.bukkit.event.Listener 9 | import org.bukkit.event.entity.EntityResurrectEvent 10 | import org.bukkit.event.inventory.InventoryAction 11 | import org.bukkit.event.inventory.InventoryClickEvent 12 | import org.bukkit.event.inventory.InventoryType 13 | import org.bukkit.event.player.PlayerSwapHandItemsEvent 14 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 15 | import java.util.* 16 | 17 | class TotemEventListener(private val plugin: HardcoreExperiment) : Listener { 18 | companion object { 19 | const val SLOT_OFF_HAND = 40 20 | } 21 | 22 | private val warnedPlayers = mutableSetOf() 23 | 24 | @EventHandler 25 | fun onTotemResurrect(event: EntityResurrectEvent) { 26 | // Отменяем любое воскрешение игрока внутриигровыми способами (например, тотем) 27 | if (event.entityType == EntityType.PLAYER) { 28 | event.isCancelled = true 29 | } 30 | } 31 | 32 | @EventHandler 33 | fun onSwapHands(event: PlayerSwapHandItemsEvent) { 34 | val player = event.player 35 | if (!player.isOp && event.offHandItem.type == Material.TOTEM_OF_UNDYING) warnPlayer(player) 36 | } 37 | 38 | @EventHandler 39 | fun onTakeOffHand(event: InventoryClickEvent) { 40 | val player = event.whoClicked 41 | if (player.isOp) return 42 | 43 | // Обрабатываем только события выкладывания на панель 44 | if (event.slotType != InventoryType.SlotType.QUICKBAR || !(event.action == InventoryAction.PLACE_ALL || event.action == InventoryAction.PLACE_SOME || event.action == InventoryAction.PLACE_ONE)) return 45 | 46 | if (event.slot == SLOT_OFF_HAND && event.cursor.type == Material.TOTEM_OF_UNDYING) warnPlayer(player) 47 | } 48 | 49 | private fun warnPlayer(player: HumanEntity) { 50 | val uuid = player.uniqueId 51 | if (warnedPlayers.contains(uuid)) return 52 | 53 | player.sendMessage( 54 | MiniMessage.miniMessage() 55 | .deserialize("<#ffff55>МИНЗДРАВ ПРЕДУПРЕЖДАЕТ: тотемы на этом сервере <#ff5555>ОТКЛЮЧЕНЫ! Держать тотем в руке нет смысла.") 56 | ) 57 | warnedPlayers.add(uuid) 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/listener/EpochEventListener.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.listener 2 | 3 | import net.kyori.adventure.text.minimessage.MiniMessage 4 | import org.bukkit.Material 5 | import org.bukkit.entity.HumanEntity 6 | import org.bukkit.event.EventHandler 7 | import org.bukkit.event.Listener 8 | import org.bukkit.event.enchantment.EnchantItemEvent 9 | import org.bukkit.event.inventory.CraftItemEvent 10 | import org.bukkit.event.inventory.FurnaceExtractEvent 11 | import org.bukkit.event.inventory.InventoryClickEvent 12 | import org.bukkit.event.player.PlayerAttemptPickupItemEvent 13 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 14 | import ru.teasanctuary.hardcore_experiment.types.WorldEpoch 15 | 16 | class EpochEventListener(private val plugin: HardcoreExperiment) : Listener { 17 | /** 18 | * Особый случай: апгрейд до эпохи Obsidian в случае успешного зачарования предмета. 19 | */ 20 | @EventHandler(ignoreCancelled = true) 21 | fun onItemEnchant(event: EnchantItemEvent) { 22 | // Предотвратим улучшение эпохи, когда оператор зачарует предмет. 23 | if (event.enchanter.isOp) return 24 | 25 | tryAllowEpoch(event.enchanter, WorldEpoch.Obsidian) 26 | } 27 | 28 | @EventHandler 29 | fun onPlayerPickItem(event: PlayerAttemptPickupItemEvent) { 30 | // Предотвратим улучшение эпохи, когда оператор поднимает предмет. 31 | if (event.player.isOp) return 32 | 33 | checkEpoch(event.player, event.item.itemStack.type) 34 | } 35 | 36 | @EventHandler 37 | fun onPlayerCraft(event: CraftItemEvent) { 38 | // Предотвратим улучшение эпохи, когда оператор крафтит предмет. 39 | if (event.whoClicked.isOp) return 40 | 41 | checkEpoch(event.whoClicked, event.recipe.result.type) 42 | } 43 | 44 | @EventHandler 45 | fun onFurnaceExtract(event: FurnaceExtractEvent) { 46 | // Предотвратим улучшение эпохи, когда оператор забирает предмет из печи. 47 | if (event.player.isOp) return 48 | 49 | checkEpoch(event.player, event.itemType) 50 | } 51 | 52 | @EventHandler 53 | fun onClickEvent(event: InventoryClickEvent) { 54 | // Предотвратим улучшение эпохи, когда оператор кликает по предмету. 55 | if (event.whoClicked.isOp) return 56 | 57 | val item = event.currentItem 58 | if (item != null) checkEpoch(event.whoClicked, item.type) 59 | } 60 | 61 | private fun checkEpoch(caller: HumanEntity, material: Material) { 62 | val epoch = WorldEpoch.itemToEpoch[material] ?: return 63 | 64 | tryAllowEpoch(caller, epoch) 65 | } 66 | 67 | private fun tryAllowEpoch(caller: HumanEntity, epoch: WorldEpoch) { 68 | val isFirstToOpenEpoch = plugin.allowEpoch(epoch) 69 | if (isFirstToOpenEpoch) plugin.notifyAdmins( 70 | MiniMessage.miniMessage() 71 | .deserialize("Игрок <#5555ff>${caller.name} открыл эпоху ${epoch.name}.") 72 | ) 73 | } 74 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/config/HardcoreExperimentConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.config 2 | 3 | import org.bukkit.configuration.serialization.ConfigurationSerializable 4 | import kotlin.reflect.KMutableProperty1 5 | import kotlin.reflect.full.memberProperties 6 | 7 | class HardcoreExperimentConfig( 8 | /** 9 | * Максимальная длительность эпохи угля 10 | * 11 | * @see ru.teasanctuary.hardcore_experiment.types.WorldEpoch.Coal 12 | */ 13 | @ConfigField var coalEpochUpgradeTimer: Int, 14 | /** 15 | * Время, которое даётся игрокам на то, чтобы зайти в игру в первый раз. По истечению новые игроки будут 16 | * появляться мёртвыми. 17 | */ 18 | @ConfigField var initialJoinTimer: Int, 19 | /** 20 | * Время, которое даётся игрокам на возрождение умершего игрока. По истечению игрок становится наблюдателем. 21 | */ 22 | @ConfigField var respawnTimeout: Int, 23 | /** 24 | * Время, за которое будет достигнута следующая эпоха, если она была помечена как разрешённая. 25 | */ 26 | @ConfigField var epochUpgradeTimer: Int, 27 | /** 28 | * Максимальное количество предметов, которое может быть заплачено за возрождение. 29 | * 30 | * Превышение данного лимита запрещает возрождение игрока. 31 | */ 32 | @ConfigField var resurrectionPriceLimit: Int 33 | ) : ConfigurationSerializable { 34 | companion object { 35 | const val DEFAULT_COAL_EPOCH_UPGRADE_TIMER = 180 36 | const val DEFAULT_INITIAL_JOIN_TIMER = 300 37 | const val DEFAULT_RESPAWN_TIMEOUT = 1200 38 | const val DEFAULT_EPOCH_UPGRADE_TIMER = 1200 // 1 игровой день = 20 реальных минут (1200 секунд) 39 | const val DEFAULT_RESURRECTION_PRICE_LIMIT = 2 * 60 // два стака 40 | 41 | val CONFIG_FIELDS = 42 | HardcoreExperimentConfig::class.memberProperties.filter { it.annotations.any { annotation -> annotation is ConfigField } } 43 | .map { it as KMutableProperty1 } 44 | } 45 | 46 | constructor(config: Map) : this( 47 | config["coal-epoch-upgrade-timer"] as? Int ?: DEFAULT_COAL_EPOCH_UPGRADE_TIMER, 48 | config["initial-join-timer"] as? Int ?: DEFAULT_INITIAL_JOIN_TIMER, 49 | config["respawn-timeout"] as? Int ?: DEFAULT_RESPAWN_TIMEOUT, 50 | config["epoch-upgrade-timer"] as? Int ?: DEFAULT_EPOCH_UPGRADE_TIMER, 51 | config["resurrection-price-limit"] as? Int ?: DEFAULT_RESURRECTION_PRICE_LIMIT 52 | ) 53 | 54 | constructor() : this( 55 | DEFAULT_COAL_EPOCH_UPGRADE_TIMER, 56 | DEFAULT_INITIAL_JOIN_TIMER, 57 | DEFAULT_RESPAWN_TIMEOUT, 58 | DEFAULT_EPOCH_UPGRADE_TIMER, 59 | DEFAULT_RESURRECTION_PRICE_LIMIT 60 | ) 61 | 62 | override fun serialize(): MutableMap { 63 | return mutableMapOf( 64 | Pair("coal-epoch-upgrade-timer", coalEpochUpgradeTimer), 65 | Pair("initial-join-timer", initialJoinTimer), 66 | Pair("respawn-timeout", respawnTimeout), 67 | Pair("epoch-upgrade-timer", epochUpgradeTimer), 68 | Pair("resurrection-price-limit", resurrectionPriceLimit) 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/listener/MortisEventListener.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.listener 2 | 3 | import com.destroystokyo.paper.event.player.PlayerPostRespawnEvent 4 | import com.destroystokyo.paper.event.player.PlayerStartSpectatingEntityEvent 5 | import net.kyori.adventure.text.minimessage.MiniMessage 6 | import org.bukkit.GameMode 7 | import org.bukkit.event.Cancellable 8 | import org.bukkit.event.EventHandler 9 | import org.bukkit.event.Listener 10 | import org.bukkit.event.entity.PlayerDeathEvent 11 | import org.bukkit.event.player.PlayerEvent 12 | import org.bukkit.event.player.PlayerGameModeChangeEvent 13 | import org.bukkit.event.player.PlayerMoveEvent 14 | import org.bukkit.event.player.PlayerTeleportEvent 15 | import ru.teasanctuary.hardcore_experiment.HardcoreExperiment 16 | import ru.teasanctuary.hardcore_experiment.types.PlayerState 17 | 18 | /** 19 | * Класс-слушатель, отвечающий за события, влияющие на состояние игрока: 20 | * 21 | * - Смерть 22 | * - Ручное изменение игрового режима 23 | * 24 | * Также данный класс обрабатывает события перемещения для ограниченных наблюдателей. 25 | */ 26 | class MortisEventListener(private val plugin: HardcoreExperiment) : Listener { 27 | @EventHandler 28 | fun onPlayerDeath(event: PlayerDeathEvent) { 29 | val player = event.player 30 | if (plugin.epoch.canRespawn()) { 31 | val respawnCost = plugin.epoch.getRespawnCost(plugin.defaultWorld.gameTime - plugin.epochSince) 32 | if (respawnCost == null || respawnCost.amount > plugin.hardcoreConfig.resurrectionPriceLimit) { 33 | // Воскрешение слишком дорогое, кидаем игрока в список мертвецов 34 | plugin.makePlayerSpectate(player) 35 | player.sendMessage( 36 | MiniMessage.miniMessage().deserialize( 37 | "<#ff5555>Вы умерли." + " Ваше возрождение стоило слишком дорого из-за долгого нахождения в одной эпохе, либо из-за частых смертей." + "\n\nУвы." 38 | ) 39 | ) 40 | } else { 41 | plugin.killPlayer(player, player.location) 42 | player.sendMessage( 43 | MiniMessage.miniMessage().deserialize( 44 | "<#ff5555>Вы умерли. <#55ff55>Вас всё ещё можно возродить!\n\n" + (if (respawnCost.amount == 0) "Для этого попросите кого-нибудь встать на алтарь" 45 | else "Для этого попросите кого-нибудь положить на алтарь предмет в количестве ${respawnCost.amount}") + " и написать команду <#55ff55>/he-resurrect ${player.name}" + "\n\n<#ffff55>На возрождение вам дано ${plugin.hardcoreConfig.respawnTimeout} секунд, поторопитесь!" 46 | ) 47 | ) 48 | } 49 | } else { 50 | plugin.makePlayerSpectate(player) 51 | } 52 | } 53 | 54 | @EventHandler 55 | fun onPlayerGameModeChange(event: PlayerGameModeChangeEvent) { 56 | if (event.cause == PlayerGameModeChangeEvent.Cause.COMMAND) { 57 | if (event.newGameMode == GameMode.SPECTATOR) plugin.makePlayerSpectate(event.player) 58 | else plugin.makePlayerAlive(event.player, event.player.location) 59 | } else if (event.cause == PlayerGameModeChangeEvent.Cause.HARDCORE_DEATH) { 60 | // Мы уже обрабатываем смерть игрока 61 | if (plugin.getPlayerState(event.player) == PlayerState.Alive) event.isCancelled = true 62 | } 63 | } 64 | 65 | @EventHandler 66 | fun onPlayerMove(event: PlayerMoveEvent) { 67 | val player = event.player 68 | if (player.gameMode == GameMode.SPECTATOR && plugin.getPlayerState(player) == PlayerState.LimitedSpectator) { 69 | val newLocation = event.from.clone().setDirection(event.to.direction) 70 | // TODO: вращение головой выглядит дёргано 71 | event.player.teleport(newLocation, PlayerTeleportEvent.TeleportCause.PLUGIN) 72 | //event.isCancelled = true 73 | } 74 | } 75 | 76 | @EventHandler 77 | fun onPlayerTeleport(event: PlayerTeleportEvent) { 78 | if (event.cause == PlayerTeleportEvent.TeleportCause.SPECTATE) cancelForLimitedSpectators(event) 79 | } 80 | 81 | @EventHandler 82 | fun onPlayerStartSpectatingEntity(event: PlayerStartSpectatingEntityEvent) { 83 | cancelForLimitedSpectators(event) 84 | } 85 | 86 | @EventHandler 87 | fun onPlayerPostRespawn(event: PlayerPostRespawnEvent) { 88 | plugin.processPlayerStateRequest(event.player) 89 | } 90 | 91 | /** 92 | * Отменяет событие, если игрок находится в режиме частичного наблюдателя 93 | */ 94 | private fun cancelForLimitedSpectators(event: T) where T : PlayerEvent, T : Cancellable { 95 | val player = event.player 96 | if (player.gameMode == GameMode.SPECTATOR && plugin.getPlayerState(player) == PlayerState.LimitedSpectator) { 97 | event.isCancelled = true 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/WorldEpoch.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import org.bukkit.Material 4 | import org.bukkit.inventory.ItemStack 5 | import java.util.* 6 | import kotlin.math.ceil 7 | import kotlin.math.pow 8 | 9 | enum class WorldEpoch(val items: List) { 10 | /** 11 | * Неинициализированная эпоха. 12 | */ 13 | Invalid(listOf()) { 14 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 15 | error("Эта эпоха не должна быть выбрана") 16 | } 17 | }, 18 | /** 19 | * Эпоха угля. Бесплатное возрождение, но длится ограниченное время. 20 | */ 21 | Coal(listOf(Material.COAL, Material.COAL_BLOCK, Material.COAL_ORE)) { 22 | override fun getRespawnCost(epochDuration: Long): ItemStack { 23 | return ItemStack.empty() // Бесплатный респаун 24 | } 25 | 26 | }, 27 | Copper( 28 | listOf( 29 | Material.RAW_COPPER, 30 | Material.COPPER_INGOT, 31 | Material.COPPER_ORE, 32 | Material.COPPER_BLOCK, 33 | Material.EXPOSED_COPPER, 34 | Material.WEATHERED_COPPER, 35 | Material.OXIDIZED_COPPER, 36 | Material.WAXED_COPPER_BLOCK, 37 | Material.WAXED_EXPOSED_COPPER, 38 | Material.WAXED_WEATHERED_COPPER, 39 | Material.WAXED_OXIDIZED_COPPER 40 | ) 41 | ) { 42 | override fun getRespawnCost(epochDuration: Long): ItemStack { 43 | // Цена растёт каждую минуту 44 | val days = maxOf(1, epochDuration / MINUTES_TO_TICKS) 45 | val count = ceil(days.toDouble().pow(1.2)).toInt() 46 | return ItemStack.of(Material.COPPER_INGOT, count) 47 | } 48 | }, 49 | Iron( 50 | listOf( 51 | Material.RAW_IRON, 52 | // Причина: может быть найден в сундуках данжей 53 | // Material.IRON_NUGGET, 54 | // Material.IRON_INGOT, 55 | Material.IRON_BLOCK, 56 | // Инструменты 57 | Material.SHIELD, 58 | Material.IRON_AXE, 59 | Material.IRON_PICKAXE, 60 | Material.IRON_HOE, 61 | Material.IRON_SHOVEL, 62 | Material.IRON_SWORD, 63 | // Броня 64 | Material.IRON_BOOTS, 65 | Material.IRON_CHESTPLATE, 66 | Material.IRON_HELMET, 67 | Material.IRON_LEGGINGS 68 | ) 69 | ) { 70 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 71 | return null 72 | } 73 | }, 74 | Gold( 75 | listOf( 76 | Material.RAW_GOLD, 77 | // Причина: может быть найден в сундуках данжей 78 | // Material.GOLD_NUGGET, 79 | // Material.GOLD_INGOT, 80 | Material.GOLD_BLOCK, 81 | // Еда 82 | Material.GOLDEN_APPLE, 83 | Material.GOLDEN_CARROT, 84 | Material.GLISTERING_MELON_SLICE, 85 | // Инструменты 86 | Material.CLOCK, 87 | Material.GOLDEN_AXE, 88 | Material.GOLDEN_PICKAXE, 89 | Material.GOLDEN_HOE, 90 | Material.GOLDEN_SHOVEL, 91 | Material.GOLDEN_SWORD, 92 | // Броня 93 | Material.GOLDEN_BOOTS, 94 | Material.GOLDEN_CHESTPLATE, 95 | Material.GOLDEN_HELMET, 96 | Material.GOLDEN_LEGGINGS, 97 | // Прочее 98 | Material.POWERED_RAIL 99 | ) 100 | ) { 101 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 102 | return null 103 | } 104 | }, 105 | Diamond( 106 | listOf( 107 | // Причина: может быть найден в сундуках данжей 108 | // Material.DIAMOND, 109 | Material.DIAMOND_BLOCK, 110 | // Инструменты 111 | Material.DIAMOND_AXE, 112 | Material.DIAMOND_HOE, 113 | Material.DIAMOND_PICKAXE, 114 | Material.DIAMOND_SHOVEL, 115 | Material.DIAMOND_SWORD, 116 | // Броня 117 | Material.DIAMOND_BOOTS, 118 | Material.DIAMOND_CHESTPLATE, 119 | Material.DIAMOND_HELMET, 120 | Material.DIAMOND_LEGGINGS 121 | ) 122 | ) { 123 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 124 | return null 125 | } 126 | }, 127 | Obsidian( 128 | listOf( 129 | Material.OBSIDIAN, Material.CRYING_OBSIDIAN, 130 | // Инструменты 131 | Material.ENCHANTING_TABLE, Material.BEACON, Material.ENDER_CHEST, Material.RESPAWN_ANCHOR, 132 | // Прочее 133 | Material.ENDER_EYE 134 | ) 135 | ) { 136 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 137 | return null 138 | } 139 | }, 140 | 141 | /** 142 | * Эпоха незерита. Возрождение невозможно. 143 | */ 144 | Netherite( 145 | listOf( 146 | Material.ANCIENT_DEBRIS, 147 | Material.NETHERITE_SCRAP, 148 | Material.NETHERITE_INGOT, 149 | Material.NETHERITE_BLOCK, 150 | // Инструменты 151 | Material.NETHERITE_AXE, 152 | Material.NETHERITE_HOE, 153 | Material.NETHERITE_PICKAXE, 154 | Material.NETHERITE_SHOVEL, 155 | Material.NETHERITE_SWORD, 156 | // Броня 157 | Material.NETHERITE_BOOTS, 158 | Material.NETHERITE_CHESTPLATE, 159 | Material.NETHERITE_HELMET, 160 | Material.NETHERITE_LEGGINGS 161 | ) 162 | ) { 163 | override fun canRespawn(): Boolean = false 164 | 165 | override fun getRespawnCost(epochDuration: Long): ItemStack? { 166 | return null 167 | } 168 | }; 169 | 170 | companion object { 171 | private const val SECONDS_TO_TICKS = 20L 172 | private const val MINUTES_TO_TICKS = 60 * SECONDS_TO_TICKS 173 | private const val DAY_LENGTH = 20 * MINUTES_TO_TICKS 174 | 175 | /** 176 | * Таблица принадлежности каждого предмета к эпохе. Формируется автоматически на основе всех эпох. 177 | * 178 | * @see WorldEpoch 179 | */ 180 | val itemToEpoch = 181 | EnumMap(WorldEpoch.entries.map { epoch -> epoch.items.associateWith { _ -> epoch } }.flatMap { it.entries } 182 | .associate { it.toPair() }) 183 | } 184 | 185 | open fun canRespawn(): Boolean = true 186 | 187 | /** 188 | * @return ItemStack, если можно возродиться. null, если стоимость возрождения слишком высока. 189 | */ 190 | abstract fun getRespawnCost(epochDuration: Long): ItemStack? 191 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/types/AltarSchematic.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment.types 2 | 3 | import net.minecraft.core.Direction 4 | import net.minecraft.core.Vec3i 5 | import net.minecraft.nbt.* 6 | import org.bukkit.Material 7 | import org.bukkit.World 8 | import org.bukkit.block.BlockFace 9 | import org.bukkit.block.data.type.Chest 10 | import java.io.File 11 | 12 | data class AltarSchematic( 13 | /** 14 | * Координаты блока сундука 15 | */ 16 | val center: Vec3i, 17 | /** 18 | * Размер структуры 19 | */ 20 | val size: Vec3i, 21 | /** 22 | * Трёхмерный массив блоков, из которых состоит алтарь. Y - вторая координата - вверх 23 | */ 24 | val blocks: Array>>, 25 | /** 26 | * Неупорядоченный массив всех возможных позиций блоков эпох относительно края структуры. 27 | */ 28 | val epochBlockLocations: Array 29 | ) { 30 | companion object { 31 | private fun getNbtVector(rootTag: CompoundTag, name: String): Vec3i { 32 | val vecNbt = rootTag.getList(name, Tag.TAG_INT.toInt()) 33 | assert(vecNbt.count() == 3) 34 | return Vec3i((vecNbt[0] as IntTag).asInt, (vecNbt[1] as IntTag).asInt, (vecNbt[2] as IntTag).asInt) 35 | } 36 | 37 | /** 38 | * Вращает вектор вокруг оси Y. 39 | */ 40 | private fun rotate(v: Vec3i, size: Vec3i, dFrom: Direction, dTo: Direction): Vec3i { 41 | val dDifference = Direction.from2DDataValue((4 + dTo.get2DDataValue() - dFrom.get2DDataValue()) % 4) 42 | return when (dDifference) { 43 | Direction.NORTH -> Vec3i(size.x - v.x - 1, v.y, size.z - v.z - 1) 44 | Direction.WEST -> Vec3i(size.z - v.z - 1, v.y, v.x) 45 | Direction.EAST -> Vec3i(v.z, v.y, size.x - v.x - 1) 46 | Direction.SOUTH -> Vec3i(v.x, v.y, v.z) 47 | else -> error("Unreachable") 48 | } 49 | } 50 | 51 | /** 52 | * Преобразовывает направление типа BlockFace в направление типа Direction. 53 | * 54 | * Не работает с неортогональными направлениями. 55 | */ 56 | private fun blockFaceToDirection(facing: BlockFace): Direction = when (facing) { 57 | BlockFace.NORTH -> Direction.NORTH 58 | BlockFace.SOUTH -> Direction.SOUTH 59 | BlockFace.WEST -> Direction.WEST 60 | BlockFace.EAST -> Direction.EAST 61 | else -> error("Unknown") 62 | } 63 | 64 | private fun rotate(v: Vec3i, size: Vec3i, fFrom: BlockFace, fTo: BlockFace): Vec3i = 65 | rotate(v, size, blockFaceToDirection(fFrom), blockFaceToDirection(fTo)) 66 | 67 | /** 68 | * Получить AltarSchematic из файла. 69 | * 70 | * Требования к файлу: 71 | * 1. Формат NBT (Vanilla Structure) 72 | * 2. Ровно один сундук по центру строения 73 | * 74 | * @see AltarSchematic 75 | */ 76 | fun fromFile(file: File): AltarSchematic { 77 | val s = file.inputStream() 78 | val tagSizeTracker = NbtAccounter.unlimitedHeap() 79 | val nbt = NbtIo.readCompressed(s, tagSizeTracker) 80 | 81 | // Заполняем "палитру" блоков. Для сундука особый случай: вытаскиваем сторону света. 82 | val paletteNbt = nbt.getList("palette", Tag.TAG_COMPOUND.toInt()) 83 | val palette = Array(paletteNbt.size) { Material.AIR } 84 | // Направление сундука определяется его замком (маленькой железной деталью спереди). 85 | var chestDirection = Direction.NORTH 86 | for ((i, tag) in paletteNbt.withIndex()) { 87 | val compoundTag = tag as CompoundTag 88 | val name = compoundTag.getString("Name") 89 | val material = Material.matchMaterial(name) 90 | material as Material 91 | palette[i] = material 92 | 93 | if (material == Material.CHEST) { 94 | val direction = Direction.byName( 95 | compoundTag.getCompound("Properties").getString("facing") 96 | ) as Direction 97 | chestDirection = direction 98 | } 99 | } 100 | 101 | var size = getNbtVector(nbt, "size") 102 | val blocksNbt = nbt.getList("blocks", Tag.TAG_COMPOUND.toInt()) 103 | val blocks = Array(size.x) { Array(size.y) { Array(size.z) { Material.AIR } } } 104 | 105 | var chestPosition: Vec3i? = null 106 | val epochLocations = mutableListOf() 107 | for (tag in blocksNbt) { 108 | val compoundTag = tag as CompoundTag 109 | val state = compoundTag.getInt("state") 110 | val material = palette[state] 111 | 112 | val pos = getNbtVector(compoundTag, "pos") 113 | // Если предмет принадлежит эпохе, то записываем позицию на будущее и исключаем из постройки 114 | val epoch = WorldEpoch.itemToEpoch[material] 115 | if (epoch != null) { 116 | epochLocations.addLast(pos) 117 | } else { 118 | blocks[pos.x][pos.y][pos.z] = material 119 | // Запоминаем позицию сундука 120 | if (material == Material.CHEST) chestPosition = pos 121 | } 122 | } 123 | 124 | // В постройке должен быть хотя бы один сундук 125 | if (chestPosition == null) error("chestPosition is null") 126 | 127 | var transposedBlocks = blocks 128 | // Приводим структуру к единому формату, где сундук смотрит на юг 129 | if (chestDirection != Direction.SOUTH) { 130 | // Меняем координаты блоков эпох местами 131 | var i = 0 132 | while (i < epochLocations.size) { 133 | val loc = epochLocations[i] 134 | epochLocations[i] = rotate(loc, size, chestDirection, Direction.SOUTH) 135 | i++ 136 | } 137 | 138 | // Нужно ли менять размер матрицы 139 | val isPerpendicular = chestDirection != Direction.NORTH 140 | transposedBlocks = 141 | if (isPerpendicular) Array(size.z) { Array(size.y) { Array(size.x) { Material.AIR } } } 142 | else Array(size.x) { Array(size.y) { Array(size.z) { Material.AIR } } } 143 | 144 | when (chestDirection) { 145 | Direction.NORTH -> { 146 | var y = 0 147 | while (y < size.y) { 148 | var x = 0 149 | while (x < size.x) { 150 | var z = 0 151 | while (z < size.z) { 152 | transposedBlocks[size.x - x - 1][y][size.z - z - 1] = blocks[x][y][z] 153 | z++ 154 | } 155 | x++ 156 | } 157 | y++ 158 | } 159 | } 160 | 161 | Direction.WEST -> { 162 | var y = 0 163 | while (y < size.y) { 164 | var x = 0 165 | while (x < size.x) { 166 | var z = 0 167 | while (z < size.z) { 168 | transposedBlocks[size.z - z - 1][y][x] = blocks[x][y][z] 169 | z++ 170 | } 171 | x++ 172 | } 173 | y++ 174 | } 175 | } 176 | 177 | Direction.EAST -> { 178 | var y = 0 179 | while (y < size.y) { 180 | var x = 0 181 | while (x < size.x) { 182 | var z = 0 183 | while (z < size.z) { 184 | transposedBlocks[z][y][size.x - x - 1] = blocks[x][y][z] 185 | z++ 186 | } 187 | x++ 188 | } 189 | y++ 190 | } 191 | } 192 | 193 | else -> error("Unreachable") 194 | } 195 | 196 | if (isPerpendicular) size = Vec3i(size.z, size.y, size.x) 197 | } 198 | 199 | return AltarSchematic(chestPosition, size, transposedBlocks, epochLocations.toTypedArray()) 200 | } 201 | 202 | private val airs = setOf( 203 | Material.AIR, Material.CAVE_AIR, Material.VOID_AIR 204 | ) 205 | private val stones = setOf( 206 | Material.COBBLESTONE, 207 | Material.ANDESITE, 208 | Material.DIORITE, 209 | Material.GRANITE, 210 | Material.DEEPSLATE, 211 | Material.BLACKSTONE 212 | ) 213 | private val stoneSlabs = setOf( 214 | Material.COBBLESTONE_SLAB, 215 | Material.ANDESITE_SLAB, 216 | Material.DIORITE_SLAB, 217 | Material.GRANITE_SLAB, 218 | Material.COBBLED_DEEPSLATE_SLAB, 219 | Material.BLACKSTONE_SLAB 220 | ) 221 | private val stoneWalls = setOf( 222 | Material.COBBLESTONE_WALL, 223 | Material.ANDESITE_WALL, 224 | Material.DIORITE_WALL, 225 | Material.GRANITE_WALL, 226 | Material.COBBLED_DEEPSLATE_WALL, 227 | Material.BLACKSTONE_WALL 228 | ) 229 | private val copperStates = setOf( 230 | Material.COPPER_BLOCK, 231 | Material.EXPOSED_COPPER, 232 | Material.WEATHERED_COPPER, 233 | Material.OXIDIZED_COPPER, 234 | Material.WAXED_COPPER_BLOCK, 235 | Material.WAXED_EXPOSED_COPPER, 236 | Material.WAXED_WEATHERED_COPPER, 237 | Material.WAXED_OXIDIZED_COPPER 238 | ) 239 | 240 | /** 241 | * Проверяет равенство двух блоков. Игнорирует некоторые блоки, например воздух. 242 | */ 243 | private fun isMaterialEqual(schematic: Material, world: Material): Boolean { 244 | // Наиболее вероятный случай: разрешаем точные совпадения 245 | // Это позволит нам реже вызывать дорогие методы Set.contains() 246 | if (schematic == world) return true 247 | 248 | // Разрешаем ставить факела где угодно 249 | if (schematic == Material.TORCH) return true 250 | 251 | // Разрешаем строить в пустых местах (да, есть несколько видов воздуха) 252 | if (airs.contains(schematic)) return true 253 | 254 | // Разрешаем заменять виды булыжника 255 | if (stones.contains(schematic)) return stones.contains(world) 256 | if (stoneSlabs.contains(schematic)) return stoneSlabs.contains(world) 257 | if (stoneWalls.contains(schematic)) return stoneWalls.contains(world) 258 | 259 | // Разрешаем оксидацию блока меди 260 | if (copperStates.contains(schematic)) return copperStates.contains(world) 261 | 262 | return false 263 | } 264 | } 265 | 266 | /** 267 | * Итерация сначала по -X, затем по -Z 268 | */ 269 | private fun nextSouth(i: Vec3i): Vec3i { 270 | if (i.x > 0) return Vec3i(i.x - 1, i.y, i.z) 271 | 272 | if (i.z > 0) return Vec3i(size.x - 1, i.y, i.z - 1) 273 | 274 | return Vec3i(size.x - 1, i.y + 1, size.z - 1) 275 | } 276 | 277 | /** 278 | * Итерация сначала по +X, затем по +Z 279 | */ 280 | private fun nextNorth(i: Vec3i): Vec3i { 281 | if (i.x < size.x - 1) return Vec3i(i.x + 1, i.y, i.z) 282 | 283 | if (i.z < size.z - 1) return Vec3i(0, i.y, i.z + 1) 284 | 285 | return Vec3i(0, i.y + 1, 0) 286 | } 287 | 288 | /** 289 | * Итерация сначала по -Z, затем по +X 290 | */ 291 | private fun nextWest(i: Vec3i): Vec3i { 292 | // Конструкция повёрнута на бок, поэтому меняем size.x и size.z местами 293 | if (i.z > 0) return Vec3i(i.x, i.y, i.z - 1) 294 | 295 | if (i.x < size.z - 1) return Vec3i(i.x + 1, i.y, size.x - 1) 296 | 297 | return Vec3i(0, i.y + 1, size.x - 1) 298 | } 299 | 300 | /** 301 | * Итерация сначала по +Z, затем по -X 302 | */ 303 | private fun nextEast(i: Vec3i): Vec3i { 304 | // Конструкция повёрнута на бок, поэтому меняем size.x и size.z местами 305 | if (i.z < size.x - 1) return Vec3i(i.x, i.y, i.z + 1) 306 | 307 | if (i.x > 0) return Vec3i(i.x - 1, i.y, 0) 308 | 309 | return Vec3i(size.z - 1, i.y + 1, 0) 310 | } 311 | 312 | /** 313 | * Проверяет алтарь на корректность. Возвращает null, если он имеет неправильную форму. 314 | * 315 | * Возвращает массив, в котором отражено наличие или отсутствие блока эпохи под неким номером, включая Invalid. 316 | * 317 | * @see WorldEpoch 318 | */ 319 | fun getEpochBlocks(chest: org.bukkit.block.Chest): Array? { 320 | val chestData = chest.blockData as Chest 321 | val chestFacing = chestData.facing 322 | assert(chestFacing.isCartesian && chestFacing != BlockFace.UP && chestFacing != BlockFace.DOWN) 323 | 324 | /* 325 | Направление в Minecraft задаётся по часовой стрелке от юга. 326 | 327 | N 328 | W E 329 | S 330 | 331 | 2 332 | 1 3 333 | 0 334 | */ 335 | 336 | val isPerpendicular = chestFacing == BlockFace.EAST || chestFacing == BlockFace.WEST 337 | // TODO: захардкодил смещение, ибо заебало 338 | val chestOrigin = 339 | if (isPerpendicular) Vec3i(chest.x + 1, chest.y, chest.z - 1) else Vec3i(chest.x - 1, chest.y, chest.z + 1) 340 | // Конструкция повёрнута на бок, поэтому меняем size.x и size.z местами 341 | val structureCenter = 342 | if (isPerpendicular) Vec3i(center.z, center.y, center.x) else Vec3i(center.x, center.y, center.z) 343 | val structureOrigin = chestOrigin.subtract(structureCenter) 344 | var relativePos = when (chestFacing) { 345 | BlockFace.NORTH -> Vec3i(0, 0, 0) 346 | BlockFace.SOUTH -> Vec3i(size.x - 1, 0, size.z - 1) 347 | BlockFace.EAST -> Vec3i(size.z - 1, 0, 0) 348 | BlockFace.WEST -> Vec3i(0, 0, size.x - 1) 349 | else -> error("Unreachable") 350 | } 351 | val iterator: (Vec3i) -> Vec3i = when (chestFacing) { 352 | BlockFace.SOUTH -> { i -> nextSouth(i) } 353 | BlockFace.NORTH -> { i -> nextNorth(i) } 354 | BlockFace.WEST -> { i -> nextWest(i) } 355 | BlockFace.EAST -> { i -> nextEast(i) } 356 | else -> error("Unreachable") 357 | } 358 | 359 | val world = chest.world 360 | var y = 0 361 | while (y < size.y) { 362 | var z = 0 363 | while (z < size.z) { 364 | var x = 0 365 | while (x < size.x) { 366 | val material = blocks[x][y][z] 367 | 368 | val worldMaterial = world.getBlockAt( 369 | structureOrigin.x + relativePos.x, 370 | structureOrigin.y + relativePos.y, 371 | structureOrigin.z + relativePos.z 372 | ).type 373 | if (!isMaterialEqual(material, worldMaterial)) return null 374 | 375 | x++ 376 | relativePos = iterator(relativePos) 377 | } 378 | z++ 379 | } 380 | y++ 381 | } 382 | 383 | val epochList = Array(WorldEpoch.entries.size) { _ -> false } 384 | for (ebl in epochBlockLocations) { 385 | val pos = rotate(ebl, size, BlockFace.SOUTH, chestFacing) 386 | 387 | val worldMaterial = world.getBlockAt( 388 | structureOrigin.x + pos.x, structureOrigin.y + pos.y, structureOrigin.z + pos.z 389 | ).type 390 | val epoch = WorldEpoch.itemToEpoch[worldMaterial] 391 | if (epoch != null) epochList[epoch.ordinal] = true 392 | } 393 | 394 | return epochList 395 | } 396 | 397 | fun build(world: World, bX: Int, bY: Int, bZ: Int, direction: BlockFace): Boolean { 398 | if (!direction.isCartesian) return false 399 | 400 | val isPerpendicular = direction == BlockFace.EAST || direction == BlockFace.WEST 401 | // TODO: захардкодил смещение, ибо заебало 402 | val chestOrigin = if (isPerpendicular) Vec3i(bX + 1, bY, bZ - 1) else Vec3i(bX - 1, bY, bZ + 1) 403 | // Конструкция повёрнута на бок, поэтому меняем size.x и size.z местами 404 | val structureCenter = 405 | if (isPerpendicular) Vec3i(center.z, center.y, center.x) else Vec3i(center.x, center.y, center.z) 406 | val structureOrigin = chestOrigin.subtract(structureCenter) 407 | var relativePos = when (direction) { 408 | BlockFace.NORTH -> Vec3i(0, 0, 0) 409 | BlockFace.SOUTH -> Vec3i(size.x - 1, 0, size.z - 1) 410 | BlockFace.EAST -> Vec3i(size.z - 1, 0, 0) 411 | BlockFace.WEST -> Vec3i(0, 0, size.x - 1) 412 | else -> error("Unreachable") 413 | } 414 | val iterator: (Vec3i) -> Vec3i = when (direction) { 415 | BlockFace.SOUTH -> { i -> nextSouth(i) } 416 | BlockFace.NORTH -> { i -> nextNorth(i) } 417 | BlockFace.WEST -> { i -> nextWest(i) } 418 | BlockFace.EAST -> { i -> nextEast(i) } 419 | else -> error("Unreachable") 420 | } 421 | 422 | var y = 0 423 | while (y < size.y) { 424 | var z = 0 425 | while (z < size.z) { 426 | var x = 0 427 | while (x < size.x) { 428 | val material = blocks[x][y][z] 429 | 430 | world.getBlockAt( 431 | structureOrigin.x + relativePos.x, 432 | structureOrigin.y + relativePos.y, 433 | structureOrigin.z + relativePos.z 434 | ).type = material 435 | 436 | x++ 437 | relativePos = iterator(relativePos) 438 | } 439 | z++ 440 | } 441 | y++ 442 | } 443 | 444 | val chestBlock = world.getBlockAt(bX, bY, bZ) 445 | if (chestBlock.type != Material.CHEST) return false 446 | val chestData = chestBlock.blockData as Chest 447 | chestData.facing = direction 448 | chestBlock.blockData = chestData 449 | 450 | return true 451 | } 452 | 453 | override fun equals(other: Any?): Boolean { 454 | if (this === other) return true 455 | if (javaClass != other?.javaClass) return false 456 | 457 | other as AltarSchematic 458 | 459 | if (center != other.center) return false 460 | if (size != other.size) return false 461 | if (!blocks.contentDeepEquals(other.blocks)) return false 462 | if (!epochBlockLocations.contentEquals(other.epochBlockLocations)) return false 463 | 464 | return true 465 | } 466 | 467 | override fun hashCode(): Int { 468 | var result = center.hashCode() 469 | result = 31 * result + size.hashCode() 470 | result = 31 * result + blocks.contentDeepHashCode() 471 | result = 31 * result + epochBlockLocations.contentHashCode() 472 | return result 473 | } 474 | } -------------------------------------------------------------------------------- /src/main/kotlin/ru/teasanctuary/hardcore_experiment/HardcoreExperiment.kt: -------------------------------------------------------------------------------- 1 | package ru.teasanctuary.hardcore_experiment 2 | 3 | import com.mojang.brigadier.Command 4 | import com.mojang.brigadier.arguments.IntegerArgumentType 5 | import io.papermc.paper.command.brigadier.Commands 6 | import io.papermc.paper.command.brigadier.argument.ArgumentTypes 7 | import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver 8 | import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents 9 | import net.kyori.adventure.text.Component 10 | import net.kyori.adventure.text.minimessage.MiniMessage 11 | import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer 12 | import org.bukkit.* 13 | import org.bukkit.block.Chest 14 | import org.bukkit.command.CommandSender 15 | import org.bukkit.configuration.serialization.ConfigurationSerialization 16 | import org.bukkit.entity.EntityType 17 | import org.bukkit.entity.Firework 18 | import org.bukkit.entity.Player 19 | import org.bukkit.event.HandlerList 20 | import org.bukkit.event.player.PlayerTeleportEvent 21 | import org.bukkit.inventory.ItemStack 22 | import org.bukkit.permissions.Permission 23 | import org.bukkit.permissions.PermissionDefault 24 | import org.bukkit.persistence.PersistentDataType 25 | import org.bukkit.plugin.java.JavaPlugin 26 | import org.bukkit.scheduler.BukkitTask 27 | import ru.teasanctuary.hardcore_experiment.config.ConfigField 28 | import ru.teasanctuary.hardcore_experiment.config.ConfigFieldArgumentType 29 | import ru.teasanctuary.hardcore_experiment.config.HardcoreExperimentConfig 30 | import ru.teasanctuary.hardcore_experiment.listener.* 31 | import ru.teasanctuary.hardcore_experiment.types.* 32 | import java.io.File 33 | import java.util.* 34 | import java.util.logging.Level 35 | import kotlin.reflect.full.createType 36 | 37 | class HardcoreExperiment : JavaPlugin() { 38 | companion object { 39 | /** 40 | * Преобразование реальных секунд к игровым. 41 | * 42 | * Пример: World.getGameTime / REAL_SECONDS_TO_GAME_TIME 43 | */ 44 | const val REAL_SECONDS_TO_GAME_TIME: Long = 20 45 | private val mmListOfDeadPlayers = MiniMessage.miniMessage().deserialize("Список мёртвых игроков:") 46 | } 47 | 48 | /** 49 | * Ключ для хранения текущей эпохи мира. 50 | * 51 | * Хранится на стороне мира. 52 | */ 53 | private val nkEpoch: NamespacedKey = NamespacedKey(this, "epoch") 54 | 55 | /** 56 | * Ключ для хранения момента времени, когда мир перешёл в текущую эпоху. 57 | * 58 | * Хранится на стороне мира. 59 | */ 60 | private val nkEpochTimestamp: NamespacedKey = NamespacedKey(this, "epoch_timestamp") 61 | 62 | /** 63 | * Ключ для хранения разрешённых эпох. 64 | * 65 | * Хранится на стороне мира. 66 | */ 67 | private val nkEpochBitmap: NamespacedKey = NamespacedKey(this, "epoch_bitmap") 68 | 69 | /** 70 | * Ключ для хранения момента времени, когда мир должен перейти в следующую эпоху. 71 | * 72 | * Хранится на стороне мира. 73 | */ 74 | private val nkNextEpochTimestamp: NamespacedKey = NamespacedKey(this, "next_epoch_timestamp") 75 | 76 | /** 77 | * Ключ для хранения списка игроков, готовых к воскрешению или умерщвлению, но ещё не присутствующих на сервере. 78 | * 79 | * Хранится на стороне мира. 80 | */ 81 | private val nkStateChangeQueue: NamespacedKey = NamespacedKey(this, "state_change_queue") 82 | 83 | /** 84 | * Ключ для хранения списка игроков, которых можно воскресить за плату. 85 | * 86 | * Хранится на стороне мира. 87 | */ 88 | private val nkDeadPlayers: NamespacedKey = NamespacedKey(this, "dead_players") 89 | 90 | /** 91 | * Ключ для хранения состояния игрока. 92 | * 93 | * Хранится на стороне игрока. 94 | */ 95 | private val nkState: NamespacedKey = NamespacedKey(this, "state") 96 | 97 | /** 98 | * Разрешение на ручное изменение эпохи. 99 | */ 100 | private val permissionManualUpgrade = Permission("hardcore_experiment.manual_upgrade", PermissionDefault.OP) 101 | 102 | /** 103 | * Разрешение на бесплатное возрождение любого игрока вне зависимости от статуса окончательной смерти. 104 | */ 105 | private val permissionResurrect = Permission("hardcore_experiment.resurrect", PermissionDefault.OP) 106 | 107 | /** 108 | * Разрешение на размещение алтаря через команду консоли. 109 | */ 110 | private val permissionBuildAltar = Permission("hardcore_experiment.build_altar", PermissionDefault.OP) 111 | 112 | /** 113 | * Разрешение на получение внутренних уведомлений. 114 | */ 115 | private val permissionNotifications = Permission("hardcore_experiment.notifications", PermissionDefault.OP) 116 | 117 | /** 118 | * Разрешение на изменения конфигурации плагина. 119 | */ 120 | private val permissionConfig = Permission("hardcore_experiment.config", PermissionDefault.OP) 121 | 122 | private var _epoch: WorldEpoch = WorldEpoch.Invalid 123 | 124 | /** 125 | * Текущая эпоха мира. 126 | */ 127 | var epoch: WorldEpoch 128 | get() = _epoch 129 | set(e) = setWorldEpoch(defaultWorld, e) 130 | 131 | /** 132 | * Момент абсолютного игрового времени в тиках, когда наступила текущая эпоха. 133 | */ 134 | var epochSince: Long = 0 135 | private set 136 | 137 | /** 138 | * Эпохи, разрешённые для перехода. 139 | */ 140 | private val epochBitmap: MutableList = 141 | WorldEpoch.entries.map { epoch -> epoch == WorldEpoch.Coal }.toMutableList() 142 | 143 | // TODO: Очень хреновое решение, надо будет потом доработать 144 | lateinit var defaultWorld: World 145 | 146 | private val playerStateChangeQueue = mutableMapOf() 147 | 148 | /** 149 | * Список мёртвых игроков, которых можно возродить. 150 | */ 151 | private val deadPlayers = mutableMapOf() 152 | 153 | lateinit var hardcoreConfig: HardcoreExperimentConfig 154 | 155 | private var coalEpochTimer: BukkitTask? = null 156 | private var nextEpochTimer: BukkitTask? = null 157 | private var deadPlayerTimers = mutableMapOf() 158 | 159 | private lateinit var altar: AltarSchematic 160 | 161 | /** 162 | * Отправляет уведомление активным администраторам и в консоль сервера. 163 | */ 164 | fun notifyAdmins(component: Component) { 165 | Bukkit.broadcast(component, permissionNotifications.name) 166 | logger.log(Level.INFO, PlainTextComponentSerializer.plainText().serialize(component)) 167 | } 168 | 169 | /** 170 | * Возвращает состояние игрока. null, если игрок ещё не играл на данном сервере. 171 | * 172 | * @see PlayerState 173 | */ 174 | fun getPlayerState(player: Player): PlayerState? { 175 | return player.persistentDataContainer.get(nkState, PlayerStateDataType()) 176 | } 177 | 178 | /** 179 | * Задаёт состояние игрока. 180 | * 181 | * @see PlayerState 182 | */ 183 | private fun setPlayerState(player: Player, state: PlayerState) { 184 | logger.log(Level.INFO, "Setting state for player \"${player.name}\": ${state.name}") 185 | player.persistentDataContainer.set(nkState, PlayerStateDataType(), state) 186 | } 187 | 188 | /** 189 | * Удаляет игрока со списка на возрождение, а также останавливает таймер. 190 | */ 191 | private fun removeFromDead(playerId: UUID) { 192 | deadPlayers.remove(playerId) 193 | val timer = deadPlayerTimers.remove(playerId) 194 | timer?.cancel() 195 | } 196 | 197 | /** 198 | * Оживляет игрока, если он "возродился" в наблюдателя и сейчас на сервере. В противном случае ставит в очередь. 199 | */ 200 | fun makePlayerAlive(player: Player, location: Location?) { 201 | val newLocation = location ?: player.world.spawnLocation 202 | if (player.isValid) { 203 | makeOnlinePlayerAlive(player, newLocation) 204 | } else { 205 | makeOfflinePlayerAlive(player.uniqueId, newLocation) 206 | } 207 | removeFromDead(player.uniqueId) 208 | } 209 | 210 | /** 211 | * Ставит в очередь игрока, не находящегося на сервере. В противном случае сразу же возрождает. 212 | */ 213 | fun makePlayerAlive(playerId: UUID, location: Location?) { 214 | val newLocation = location ?: defaultWorld.spawnLocation 215 | val player = Bukkit.getPlayer(playerId) 216 | if (player != null && player.isValid) { 217 | makeOnlinePlayerAlive(player, newLocation) 218 | } else { 219 | makeOfflinePlayerAlive(playerId, newLocation) 220 | } 221 | removeFromDead(playerId) 222 | } 223 | 224 | /** 225 | * Воскрешает игрока, находящегося на сервере. 226 | */ 227 | private fun makeOnlinePlayerAlive(player: Player, location: Location) { 228 | assert(player.isValid) 229 | 230 | val previousState = getPlayerState(player) 231 | if (previousState != null && previousState != PlayerState.Alive) { 232 | Bukkit.broadcast( 233 | MiniMessage.miniMessage().deserialize("Игрок <#55ff55>${player.name} возродился!") 234 | ) 235 | makeFireworks(location) 236 | } 237 | 238 | setPlayerState(player, PlayerState.Alive) 239 | player.gameMode = GameMode.SURVIVAL 240 | player.teleport(location, PlayerTeleportEvent.TeleportCause.PLUGIN) 241 | } 242 | 243 | /** 244 | * Ставит игрока, вышедшего с сервера, в очередь на воскрешение. 245 | */ 246 | private fun makeOfflinePlayerAlive(playerId: UUID, location: Location) { 247 | val player = Bukkit.getOfflinePlayer(playerId) 248 | assert(player.hasPlayedBefore()) 249 | 250 | playerStateChangeQueue[playerId] = PlayerStateChangeRequest(PlayerState.Alive, location) 251 | Bukkit.broadcast( 252 | MiniMessage.miniMessage() 253 | .deserialize("Игрок <#55ff55>${player.name} поставлен в очередь на возрождение.") 254 | ) 255 | } 256 | 257 | /** 258 | * Убивает игрока, добавляя его в список возрождаемых игроков. 259 | */ 260 | fun killPlayer(player: Player, location: Location) { 261 | assert(player.isValid) 262 | 263 | val playerId = player.uniqueId 264 | val timeout = hardcoreConfig.respawnTimeout * REAL_SECONDS_TO_GAME_TIME 265 | deadPlayers[playerId] = DeadPlayerStatus( 266 | defaultWorld.gameTime - epochSince, epoch, defaultWorld.gameTime + timeout 267 | ) 268 | startDeathTimer(playerId, timeout) 269 | 270 | makePlayerSpectateLimited(player, location) 271 | } 272 | 273 | /** 274 | * Делает игрока ограниченным наблюдателем (не дальше своего сундука с лутом). 275 | */ 276 | fun makePlayerSpectateLimited(player: Player, location: Location) { 277 | // if (player.isValid) { 278 | makeOnlinePlayerSpectateLimited(player, location) 279 | // } else { 280 | // makeOfflinePlayerSpectateLimited(player.uniqueId, location) 281 | // } 282 | } 283 | 284 | /** 285 | * Делает игрока ограниченным наблюдателем (не дальше своего сундука с лутом). 286 | */ 287 | fun makePlayerSpectateLimited(playerId: UUID, location: Location) { 288 | val player = Bukkit.getPlayer(playerId) 289 | if (player != null && player.isValid) { 290 | makeOnlinePlayerSpectateLimited(player, location) 291 | } else { 292 | makeOfflinePlayerSpectateLimited(playerId, location) 293 | } 294 | } 295 | 296 | private fun makeOnlinePlayerSpectateLimited(player: Player, location: Location) { 297 | assert(player.isValid) 298 | 299 | setPlayerState(player, PlayerState.LimitedSpectator) 300 | player.gameMode = GameMode.SPECTATOR 301 | player.teleport(location, PlayerTeleportEvent.TeleportCause.PLUGIN) 302 | } 303 | 304 | /** 305 | * Ставит игрока в очередь на ограниченное наблюдение. 306 | */ 307 | private fun makeOfflinePlayerSpectateLimited(playerId: UUID, location: Location) { 308 | val player = Bukkit.getOfflinePlayer(playerId) 309 | assert(player.hasPlayedBefore()) 310 | 311 | playerStateChangeQueue[playerId] = PlayerStateChangeRequest(PlayerState.LimitedSpectator, location) 312 | } 313 | 314 | /** 315 | * Делает игрока наблюдателем. 316 | */ 317 | fun makePlayerSpectate(player: Player, location: Location? = null) { 318 | if (player.isValid) { 319 | makeOnlinePlayerSpectate(player, location ?: player.world.spawnLocation) 320 | } else { 321 | makeOfflinePlayerSpectate(player.uniqueId, location ?: defaultWorld.spawnLocation) 322 | } 323 | 324 | removeFromDead(player.uniqueId) 325 | } 326 | 327 | /** 328 | * Делает игрока наблюдателем. 329 | */ 330 | fun makePlayerSpectate(playerId: UUID, location: Location? = null) { 331 | val player = Bukkit.getPlayer(playerId) 332 | if (player != null && player.isValid) { 333 | makeOnlinePlayerSpectate(player, location ?: player.world.spawnLocation) 334 | } else { 335 | makeOfflinePlayerSpectate(playerId, location ?: defaultWorld.spawnLocation) 336 | } 337 | 338 | removeFromDead(playerId) 339 | } 340 | 341 | /** 342 | * Ставит игрока в очередь на перманентное наблюдение. 343 | */ 344 | private fun makeOnlinePlayerSpectate(player: Player, location: Location) { 345 | assert(player.isValid) 346 | 347 | setPlayerState(player, PlayerState.Spectator) 348 | player.gameMode = GameMode.SPECTATOR 349 | player.teleport(player.world.spawnLocation, PlayerTeleportEvent.TeleportCause.PLUGIN) 350 | } 351 | 352 | /** 353 | * Ставит игрока в очередь на перманентное наблюдение. 354 | */ 355 | private fun makeOfflinePlayerSpectate(playerId: UUID, location: Location) { 356 | val player = Bukkit.getOfflinePlayer(playerId) 357 | assert(player.hasPlayedBefore()) 358 | 359 | playerStateChangeQueue[playerId] = PlayerStateChangeRequest(PlayerState.Spectator, location) 360 | } 361 | 362 | /** 363 | * Обработать запрос на изменение состояния для живого игрока, если таковой имеется. 364 | */ 365 | fun processPlayerStateRequest(player: Player) { 366 | val playerUid = player.uniqueId 367 | val request = playerStateChangeQueue.remove(playerUid) 368 | if (request != null) { 369 | when (request.state) { 370 | PlayerState.Alive -> makePlayerAlive(player, request.location) 371 | PlayerState.LimitedSpectator -> makePlayerSpectateLimited(player, request.location) 372 | PlayerState.Spectator -> makePlayerSpectate(player) 373 | else -> TODO("Type not supported: ${request.state}") 374 | } 375 | } 376 | } 377 | 378 | /** 379 | * Запускает таймер для конкретного игрока. 380 | */ 381 | private fun startDeathTimer(playerId: UUID, timeout: Long) { 382 | assert(!deadPlayerTimers.contains(playerId)) 383 | 384 | deadPlayerTimers[playerId] = server.scheduler.runTaskLater(this, Runnable { 385 | val player = Bukkit.getPlayer(playerId) 386 | makePlayerSpectate(playerId, if (player != null && player.isValid) player.location else null) 387 | 388 | Bukkit.broadcast( 389 | MiniMessage.miniMessage().deserialize( 390 | "Игрока: <#55ff55>${Bukkit.getOfflinePlayer(playerId).name} больше <#ff5555>НЕЛЬЗЯ ВОЗРОДИТЬ.\n\n" + "Используйте алтари для возрождения тех, у кого ещё не истёк таймер. Список можно получить с помощью команды <#55ff55>/he-resurrect" 391 | ) 392 | ) 393 | }, timeout) 394 | } 395 | 396 | /** 397 | * Запускает таймер на переход из начальной эпохи в следующую. Работает только для эпохи угля. 398 | */ 399 | private fun handleCoalEpochTimer() { 400 | if (epoch != WorldEpoch.Coal || coalEpochTimer != null) return 401 | 402 | val nextEpoch = WorldEpoch.entries[WorldEpoch.Coal.ordinal + 1] 403 | val epochDuration = defaultWorld.gameTime - epochSince 404 | // Таймер уже истёк, настало время следующей эпохи 405 | if (epochDuration >= hardcoreConfig.coalEpochUpgradeTimer * REAL_SECONDS_TO_GAME_TIME) { 406 | epoch = nextEpoch 407 | return 408 | } 409 | 410 | coalEpochTimer = server.scheduler.runTaskLater(this, Runnable { 411 | if (epoch == WorldEpoch.Coal) epoch = nextEpoch 412 | coalEpochTimer = null 413 | }, hardcoreConfig.coalEpochUpgradeTimer * REAL_SECONDS_TO_GAME_TIME - epochDuration) 414 | } 415 | 416 | /** 417 | * Запускает таймер на переход в следующую разрешённую эпоху. 418 | * 419 | * Работает для всех эпох, кроме самой первой (см. handleCoalEpochTimer) и самой последней (по очевидным причинам). 420 | */ 421 | private fun handleNextEpochTimer() { 422 | if (epoch == WorldEpoch.Coal || epoch.ordinal == WorldEpoch.entries.size - 1 || nextEpochTimer != null) return 423 | 424 | val nextEpoch = WorldEpoch.entries[epoch.ordinal + 1] 425 | // Переход на следующую эпоху пока что не дозволен 426 | if (!epochBitmap[nextEpoch.ordinal]) return 427 | 428 | val nextEpochDeadline = 429 | defaultWorld.persistentDataContainer.getOrDefault(nkNextEpochTimestamp, PersistentDataType.LONG, -1) 430 | // Если таймер был активен до выключения сервера, то восстанавливаемся 431 | var delay = hardcoreConfig.epochUpgradeTimer * REAL_SECONDS_TO_GAME_TIME 432 | if (nextEpochDeadline != -1L) { 433 | delay = nextEpochDeadline - defaultWorld.gameTime 434 | } else { 435 | // Запоминаем на случай выключения сервера 436 | defaultWorld.persistentDataContainer.set( 437 | nkNextEpochTimestamp, PersistentDataType.LONG, defaultWorld.gameTime + delay 438 | ) 439 | } 440 | 441 | nextEpochTimer = server.scheduler.runTaskLater(this, Runnable { 442 | // Сначала сбрасываем таймер 443 | nextEpochTimer = null 444 | defaultWorld.persistentDataContainer.set( 445 | nkNextEpochTimestamp, PersistentDataType.LONG, -1 446 | ) 447 | 448 | // Сеттер эпохи сам вызовет handleNextEpochTimer, если необходимо 449 | epoch = nextEpoch 450 | }, delay) 451 | } 452 | 453 | /** 454 | * Запускает таймеры для обновления эпохи угля и удаления игроков из списка на возрождение. 455 | * 456 | * Выполняется только при запуске сервера. 457 | */ 458 | private fun initializeDeathTimers() { 459 | val deadIterator = deadPlayers.iterator() 460 | while (deadIterator.hasNext()) { 461 | val (playerId, request) = deadIterator.next() 462 | if (request.deadline <= defaultWorld.gameTime) { 463 | deadIterator.remove() 464 | continue 465 | } 466 | 467 | startDeathTimer(playerId, request.deadline - defaultWorld.gameTime) 468 | } 469 | } 470 | 471 | /** 472 | * Помечает эпоху как доступную для перехода. Если эпоха в аргументе находится после текущей эпохи, 473 | * то текущая эпоха улучшится до тех пор, пока её следующий сосед не будет запрещён, либо пока эпохи не закончатся. 474 | * 475 | * Возвращает true, если эпоха до этого была запрещена к переходу, и false в противном случае. 476 | */ 477 | fun allowEpoch(epoch: WorldEpoch): Boolean { 478 | val ordinal = epoch.ordinal 479 | val currentOrdinal = this.epoch.ordinal 480 | // Пропускаем уже пройденные эпохи 481 | if (ordinal <= currentOrdinal) return false 482 | // Игнорируем уже разрешённые эпохи 483 | if (epochBitmap[ordinal]) return false 484 | 485 | epochBitmap[ordinal] = true 486 | handleNextEpochTimer() 487 | return true 488 | } 489 | 490 | /** 491 | * Изменяет эпоху развития игрока, а также запоминает момент времени, в который эпоха поменялась. 492 | */ 493 | private fun setWorldEpoch(world: World, epoch: WorldEpoch) { 494 | if (epoch == WorldEpoch.Invalid) error("Нельзя выбирать эпоху Invalid") 495 | 496 | if (_epoch != epoch) { 497 | _epoch = epoch 498 | epochSince = world.gameTime 499 | var i = 0 500 | while (i <= epoch.ordinal) { 501 | epochBitmap[i] = true 502 | i++ 503 | } 504 | 505 | Bukkit.broadcast( 506 | MiniMessage.miniMessage().deserialize( 507 | "Достигнута новая эпоха: <#5555ff>${epoch.name}\n\nВозрождение " + (if (epoch.canRespawn()) "<#55ff55>РАЗРЕШЕНО. Не забудьте обновить ваши алтари!" else "<#ff5555>ЗАПРЕЩЕНО. Удачи!") 508 | ) 509 | ) 510 | 511 | handleCoalEpochTimer() 512 | handleNextEpochTimer() 513 | } 514 | } 515 | 516 | fun sendCurrentEpoch(sender: CommandSender): Int { 517 | sender.sendMessage("Текущая эпоха ${epoch.name} длится ${(defaultWorld.gameTime - epochSince) / 20} секунд") 518 | 519 | return Command.SINGLE_SUCCESS 520 | } 521 | 522 | /** 523 | * Запускает фейерверк по некоторым координатам. 524 | */ 525 | private fun makeFireworks(location: Location) { 526 | val firework = location.getWorld().spawnEntity(location, EntityType.FIREWORK_ROCKET) as Firework 527 | 528 | val meta = firework.fireworkMeta 529 | meta.power = 1 // 1.5 секунды полёта 530 | meta.addEffect( 531 | FireworkEffect.builder().with(FireworkEffect.Type.BURST).withTrail() 532 | .withColor(Color.LIME, Color.WHITE, Color.YELLOW, Color.RED, Color.BLUE).flicker(true).build() 533 | ) 534 | firework.fireworkMeta = meta 535 | } 536 | 537 | /** 538 | * Загружает данные из Permanent Data Container мира или инициализирует их, если необходимо. 539 | */ 540 | private fun loadWorldStorage() { 541 | val dataEpoch = 542 | defaultWorld.persistentDataContainer.getOrDefault(nkEpoch, WorldEpochDataType(), WorldEpoch.Invalid) 543 | // Отсутствие эпохи принимаем как признак отсутствия остальных полей. 544 | if (dataEpoch == WorldEpoch.Invalid) { 545 | epoch = WorldEpoch.Coal 546 | 547 | saveWorldStorage() 548 | return 549 | } 550 | 551 | // Загружаем worldEpochBitmap до обработки таймеров 552 | val worldEpochBitmap = defaultWorld.persistentDataContainer.getOrDefault(nkEpochBitmap, 553 | PersistentDataType.LIST.listTypeFrom( 554 | PersistentDataType.BOOLEAN 555 | ), 556 | WorldEpoch.entries.map { epoch -> epoch == WorldEpoch.Coal || epoch == WorldEpoch.Invalid } // Разрешаем первые две эпохи по-умолчанию 557 | .toList()) 558 | worldEpochBitmap.forEachIndexed { index, b -> epochBitmap[index] = b } 559 | 560 | _epoch = dataEpoch 561 | epochSince = defaultWorld.persistentDataContainer.getOrDefault(nkEpochTimestamp, PersistentDataType.LONG, 0) 562 | handleCoalEpochTimer() 563 | handleNextEpochTimer() 564 | 565 | val deadPlayersList: List = defaultWorld.persistentDataContainer.getOrDefault( 566 | nkDeadPlayers, PersistentDataType.LIST.listTypeFrom(DeadPlayersListDataType()), listOf() 567 | ) 568 | deadPlayersList.forEach { pair -> 569 | deadPlayers[pair.player] = pair.status 570 | } 571 | 572 | val stateChangeQueue: List = defaultWorld.persistentDataContainer.getOrDefault( 573 | nkStateChangeQueue, PersistentDataType.LIST.listTypeFrom(StateChangeQueueDataType()), listOf() 574 | ) 575 | stateChangeQueue.forEach { pair -> 576 | playerStateChangeQueue[pair.player] = pair.state 577 | } 578 | } 579 | 580 | /** 581 | * Сохраняет данные в Permanent Data Container мира. 582 | */ 583 | fun saveWorldStorage() { 584 | defaultWorld.persistentDataContainer.set(nkEpoch, WorldEpochDataType(), epoch) 585 | defaultWorld.persistentDataContainer.set(nkEpochTimestamp, PersistentDataType.LONG, epochSince) 586 | defaultWorld.persistentDataContainer.set( 587 | nkEpochBitmap, PersistentDataType.LIST.listTypeFrom(PersistentDataType.BOOLEAN), epochBitmap 588 | ) 589 | 590 | defaultWorld.persistentDataContainer.set( 591 | nkDeadPlayers, 592 | PersistentDataType.LIST.listTypeFrom(DeadPlayersListDataType()), 593 | deadPlayers.map { pair -> DeadPlayerPair(pair.key, pair.value) }.toList() 594 | ) 595 | 596 | defaultWorld.persistentDataContainer.set( 597 | nkStateChangeQueue, 598 | PersistentDataType.LIST.listTypeFrom(StateChangeQueueDataType()), 599 | playerStateChangeQueue.map { pair -> StateChangePair(pair.key, pair.value) }.toList() 600 | ) 601 | } 602 | 603 | /** 604 | * Получает файл из папки настроек плагина. Копирует шаблон из ресурсов Jar-архива, если файл не найден. 605 | */ 606 | private fun getCustomFile(path: String): File { 607 | val file = File(dataFolder, path) 608 | if (!file.exists()) { 609 | saveResource(path, false) 610 | return File(dataFolder, path) 611 | } 612 | return file 613 | } 614 | 615 | override fun onEnable() { 616 | saveDefaultConfig() 617 | ConfigurationSerialization.registerClass(HardcoreExperimentConfig::class.java) 618 | hardcoreConfig = getConfig().getSerializable( 619 | "hardcore-experiment", HardcoreExperimentConfig::class.java, HardcoreExperimentConfig(mapOf()) 620 | ) ?: HardcoreExperimentConfig() 621 | 622 | val altarFile = getCustomFile("altar.nbt") 623 | altar = AltarSchematic.fromFile(altarFile) 624 | 625 | defaultWorld = Bukkit.getServer().worlds[0] 626 | loadWorldStorage() 627 | initializeDeathTimers() 628 | 629 | Bukkit.getPluginManager().addPermission(permissionManualUpgrade) 630 | Bukkit.getPluginManager().addPermission(permissionResurrect) 631 | Bukkit.getPluginManager().addPermission(permissionBuildAltar) 632 | Bukkit.getPluginManager().addPermission(permissionNotifications) 633 | Bukkit.getPluginManager().addPermission(permissionConfig) 634 | 635 | Bukkit.getPluginManager().registerEvents(JoinEventListener(this), this) 636 | Bukkit.getPluginManager().registerEvents(MortisEventListener(this), this) 637 | Bukkit.getPluginManager().registerEvents(WorldEventListener(this), this) 638 | Bukkit.getPluginManager().registerEvents(EpochEventListener(this), this) 639 | Bukkit.getPluginManager().registerEvents(TotemEventListener(this), this) 640 | 641 | lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event -> 642 | val commands = event.registrar() 643 | 644 | commands.register( 645 | Commands.literal("he-config").requires { source -> source.sender.hasPermission(permissionConfig) }.then( 646 | Commands.argument("property", ConfigFieldArgumentType()) 647 | // TODO: другие типы кроме Integer 648 | .then(Commands.argument("integer", IntegerArgumentType.integer()).executes { ctx -> 649 | val property = ctx.getArgument("property", ConfigField::class.java) 650 | val value = ctx.getArgument("integer", Int::class.java) 651 | val field = 652 | HardcoreExperimentConfig.CONFIG_FIELDS.first { it.annotations.contains(property) } 653 | if (field.getter.returnType != Int::class.createType()) { 654 | ctx.source.sender.sendMessage("Данное поле имеет тип, отличный от Int. (${field.getter.returnType})") 655 | 656 | return@executes Command.SINGLE_SUCCESS 657 | } 658 | 659 | try { 660 | field.setter.call(hardcoreConfig, value) 661 | ctx.source.sender.sendMessage("Значение изменено на ${field.getter.call(hardcoreConfig)}.") 662 | } catch (exception: Exception) { 663 | ctx.source.sender.sendMessage("Возникло исключение: $exception") 664 | } 665 | 666 | Command.SINGLE_SUCCESS 667 | }) 668 | ).build(), "Изменить эпоху развития игрока вручную." 669 | ) 670 | 671 | commands.register(Commands.literal("he-epoch-set") 672 | .requires { source -> source.sender.hasPermission(permissionManualUpgrade) } 673 | .then(Commands.argument("epoch", WorldEpochArgumentType()).executes { ctx -> 674 | val epoch = ctx.getArgument("epoch", WorldEpoch::class.java) 675 | 676 | nextEpochTimer?.cancel() 677 | this.epoch = epoch 678 | 679 | Command.SINGLE_SUCCESS 680 | }).build(), "Изменить эпоху развития игрока вручную." 681 | ) 682 | 683 | commands.register( 684 | Commands.literal("he-epoch").executes { ctx -> 685 | sendCurrentEpoch(ctx.source.sender) 686 | }.build(), "Получить эпоху развития всего сервера." 687 | ) 688 | 689 | commands.register(Commands.literal("he-admin-resurrect") 690 | .requires { source -> source.sender.hasPermission(permissionResurrect) }.executes { ctx -> 691 | if (ctx.source.sender is Player) makePlayerAlive(ctx.source.sender as Player, null) 692 | 693 | Command.SINGLE_SUCCESS 694 | }.then( 695 | Commands.literal("name").then(Commands.argument("players", ArgumentTypes.player()).executes { ctx -> 696 | val playerSelector = ctx.getArgument("players", PlayerSelectorArgumentResolver::class.java) 697 | val players = playerSelector.resolve(ctx.source) 698 | if (players.isNotEmpty()) { 699 | makePlayerAlive(players[0], null) 700 | } 701 | 702 | Command.SINGLE_SUCCESS 703 | }) 704 | ).then( 705 | Commands.literal("uuid") 706 | .then(Commands.argument("player_uuid", ArgumentTypes.uuid()).executes { ctx -> 707 | val uuid = ctx.getArgument("player_uuid", UUID::class.java) 708 | val offlinePlayer = Bukkit.getOfflinePlayer(uuid) 709 | if (!offlinePlayer.hasPlayedBefore()) { 710 | ctx.source.sender.sendMessage("Данный игрок никогда не играл на этом сервере.") 711 | return@executes Command.SINGLE_SUCCESS 712 | } 713 | 714 | makePlayerAlive(uuid, null) 715 | 716 | Command.SINGLE_SUCCESS 717 | }) 718 | ).build(), "Вручную возродить игрока." 719 | ) 720 | 721 | commands.register(Commands.literal("he-resurrect").executes { ctx -> 722 | ctx.source.sender.sendMessage(mmListOfDeadPlayers) 723 | deadPlayers.toList().sortedBy { pair -> pair.second.getTimeLeft(this) }.forEach { pair -> 724 | val player = Bukkit.getOfflinePlayer(pair.first) 725 | val cost: ItemStack = pair.second.getCost() ?: return@forEach 726 | // TODO: возможная уязвимость с инъекцией в сообщение 727 | ctx.source.sender.sendMessage( 728 | MiniMessage.miniMessage().deserialize( 729 | "<#ff5555>${player.name}: " + "Осталось ${(pair.second.getTimeLeft(this)) / REAL_SECONDS_TO_GAME_TIME} секунд, чтобы возродить за " + (if (cost.amount == 0) "бесплатно" 730 | else " в количестве ${cost.amount}") 731 | ) 732 | ) 733 | } 734 | 735 | Command.SINGLE_SUCCESS 736 | }.then(Commands.argument("player", ArgumentTypes.player()).requires { source -> 737 | // Позволим выполнить команду только живому игроку 738 | source.sender is Player && getPlayerState(source.sender as Player) == PlayerState.Alive 739 | }.executes { ctx -> 740 | val callingPlayer = ctx.source.sender as Player 741 | val block = callingPlayer.getTargetBlockExact(5) 742 | if (block == null || block.type != Material.CHEST) { 743 | callingPlayer.sendMessage("Вы должны смотреть на блок сундука.") 744 | 745 | return@executes Command.SINGLE_SUCCESS 746 | } 747 | 748 | val playerSelector = ctx.getArgument("player", PlayerSelectorArgumentResolver::class.java) 749 | val players = playerSelector.resolve(ctx.source) 750 | if (players.isEmpty()) { 751 | callingPlayer.sendMessage("Игрок не найден.") 752 | 753 | return@executes Command.SINGLE_SUCCESS 754 | } 755 | 756 | val resurrectedPlayer = players[0] 757 | val state = getPlayerState(resurrectedPlayer) 758 | val deadPlayer = deadPlayers[resurrectedPlayer.uniqueId] 759 | if (state != PlayerState.LimitedSpectator || deadPlayer == null) { 760 | if (state == PlayerState.Alive) { 761 | callingPlayer.sendMessage("Игрок ${resurrectedPlayer.name} не найден в списке мёртвых. Пока.") 762 | } else if (state == PlayerState.Spectator) { 763 | callingPlayer.sendMessage("Игрока ${resurrectedPlayer.name} уже нельзя возродить. Увы.") 764 | } 765 | 766 | return@executes Command.SINGLE_SUCCESS 767 | } 768 | 769 | val blockState = block.state as Chest 770 | val epochs = altar.getEpochBlocks(blockState) 771 | if (epochs == null) { 772 | callingPlayer.sendMessage("Алтарь имеет неправильную форму. Пожалуйста, проконсультируйтесь с гейм мастером.") 773 | return@executes Command.SINGLE_SUCCESS 774 | } 775 | 776 | val deadPlayerEpoch = deadPlayer.epoch 777 | var i = deadPlayerEpoch.ordinal 778 | // На алтаре должны присутствовать все блоки эпох вплоть до той эпохи, в которой умер игрок. 779 | // Если все блоки не помещаются, то выбираются те, что из наиболее поздних эпох. 780 | // Например, если игрок умер на третьей эпохе, но в алтарь помещаются только два блока эпохи, то 781 | // на алтаре должны стоять блоки двух последних эпох. 782 | while (i > WorldEpoch.Invalid.ordinal && i > deadPlayerEpoch.ordinal - altar.epochBlockLocations.size) { 783 | if (!epochs[i]) { 784 | callingPlayer.sendMessage("Алтарь не содержит блок эпохи ${WorldEpoch.entries[i].name}, возрождение невозможно.") 785 | return@executes Command.SINGLE_SUCCESS 786 | } 787 | 788 | i-- 789 | } 790 | 791 | val cost = deadPlayer.getCost() 792 | if (cost != null) { 793 | val left = blockState.blockInventory.removeItem(cost) 794 | if (left.isNotEmpty()) { 795 | callingPlayer.sendMessage("В алтаре недостаточно , положите как минимум ${cost.amount} штук.") 796 | return@executes Command.SINGLE_SUCCESS 797 | } 798 | } 799 | 800 | makePlayerAlive(resurrectedPlayer, block.location.toCenterLocation().add(0.0, 1.0, 0.0)) 801 | 802 | Command.SINGLE_SUCCESS 803 | }).build(), 804 | "Получить список мёртвых игроков, готовых к возрождению. При указании имени, попытаться возродить игрока." 805 | ) 806 | 807 | commands.register(Commands.literal("he-build-altar") 808 | .requires { source -> source.sender is Player && source.sender.hasPermission(permissionBuildAltar) } 809 | .executes { ctx -> 810 | val player = ctx.source.sender as Player 811 | val result = altar.build( 812 | player.world, 813 | player.location.blockX, 814 | player.location.blockY, 815 | player.location.blockZ, 816 | player.facing 817 | ) 818 | if (result) ctx.source.sender.sendMessage("Готово. Рекомендую вызвать команду ещё раз, чтобы факелы поставились правильно.") 819 | else ctx.source.sender.sendMessage("Ошибка: направь голову строго вдоль блоков, не по диагонали.") 820 | 821 | Command.SINGLE_SUCCESS 822 | }.build(), "Для админов: возвести алтарь" 823 | ) 824 | 825 | commands.register(Commands.literal("he-verify-altar") 826 | .requires { source -> source.sender is Player && source.sender.hasPermission(permissionBuildAltar) } 827 | .executes { ctx -> 828 | val player = ctx.source.sender as Player 829 | val block = player.getTargetBlockExact(10) 830 | if (block == null || block.type != Material.CHEST) { 831 | ctx.source.sender.sendMessage("Вы должны смотреть на блок сундука.") 832 | 833 | return@executes Command.SINGLE_SUCCESS 834 | } 835 | val blockState = block.state as Chest 836 | val epochs = altar.getEpochBlocks(blockState) 837 | ctx.source.sender.sendMessage(if (epochs != null) "Правильно" else "Неправильно") 838 | if (epochs != null) ctx.source.sender.sendMessage(epochs.joinToString(" ")) 839 | 840 | Command.SINGLE_SUCCESS 841 | }.build(), "Для админов: проверить алтарь на корректность" 842 | ) 843 | } 844 | } 845 | 846 | override fun onDisable() { 847 | saveWorldStorage() 848 | 849 | getConfig().set("hardcore-experiment", hardcoreConfig) 850 | saveConfig() 851 | 852 | HandlerList.unregisterAll(this) 853 | } 854 | } 855 | --------------------------------------------------------------------------------