diff --git a/data/packets.yml b/data/packets.yml index dbcf66aca..0d6b80f82 100644 --- a/data/packets.yml +++ b/data/packets.yml @@ -621,11 +621,13 @@ in-packets: length: 4 ignore: true - - message: gg.rsmod.game.message.impl.IgnoreMessage # No data + - message: gg.rsmod.game.message.impl.SoundSongEndMessage # No data type: FIXED opcode: 75 length: 4 - ignore: true + structure: + - name: songId + type: INT - message: gg.rsmod.game.message.impl.IgnoreMessage # No data type: FIXED @@ -1163,4 +1165,4 @@ in-packets: - name: height type: SHORT - name: display_mode - type: BYTE \ No newline at end of file + type: BYTE diff --git a/game/plugins/src/main/kotlin/gg/rsmod/plugins/api/ext/PlayerExt.kt b/game/plugins/src/main/kotlin/gg/rsmod/plugins/api/ext/PlayerExt.kt index f0ccadbea..35d191acd 100644 --- a/game/plugins/src/main/kotlin/gg/rsmod/plugins/api/ext/PlayerExt.kt +++ b/game/plugins/src/main/kotlin/gg/rsmod/plugins/api/ext/PlayerExt.kt @@ -1,5 +1,6 @@ package gg.rsmod.plugins.api.ext +import gg.rsmod.game.fs.def.EnumDef import gg.rsmod.game.fs.def.ItemDef import gg.rsmod.game.fs.def.VarbitDef import gg.rsmod.game.message.impl.* @@ -20,6 +21,7 @@ import gg.rsmod.plugins.api.cfg.Items import gg.rsmod.plugins.api.cfg.Sfx import gg.rsmod.plugins.content.combat.createProjectile import gg.rsmod.plugins.content.combat.strategy.MagicCombatStrategy +import gg.rsmod.plugins.content.mechanics.music.RegionMusicService import gg.rsmod.plugins.content.quests.QUEST_POINT_VARP import gg.rsmod.plugins.content.quests.Quest import gg.rsmod.plugins.content.skills.crafting.jewellery.JewelleryData @@ -605,12 +607,135 @@ fun Player.playJingle( write(MusicEffectMessage(id = id, volume = volume)) } +/** + * Sends a [MidiSongMessage] to the [Player] based on the provided [id]. The player's music tab + * interface is also updated using the provided [name]. The player's currently playing song varbit + * is also updated. + * + * @param id: The ID of the song to be played + * @param name: The name of the song to be played + */ fun Player.playSong( id: Int, name: String = "", ) { setComponentText(interfaceId = 187, component = 4, text = name) write(MidiSongMessage(10, id, 255)) + + val index = + world.definitions + .get(EnumDef::class.java, 1351) + .getKeyForValue(id) + setVarbit(4388, index) +} + +/** + * Unlocks a song for the player based on trackIndex and writes a message to the player's chat window. + * + * @param trackIndex: The index used to identify the song the cache + * @param sendMessage: Whether to send a message to the [Player] when this song is unlocked + */ +fun Player.unlockSong( + trackIndex: Int, + sendMessage: Boolean = true, +) { + val musicTrack = + world + .getService( + RegionMusicService::class.java, + )!! + .musicTrackList + .first { trackIndex == it.index } + + val bitNum = musicTrack.bitNum + val oldValue = getVarp(musicTrack.varp) + + // Return if the player has already unlocked this song + if (oldValue shr bitNum and 1 == 1) { + return + } + + val newValue = (1 shl bitNum) + oldValue + setVarp(musicTrack.varp, newValue) + + val trackName = world.definitions.get(EnumDef::class.java, 1345).getString(trackIndex) + if (sendMessage) { + message( + "You have unlocked a new music track: $trackName", + ChatMessageType + .GAME_MESSAGE, + ) + } +} + +/** + * Add a song to the [Player]s playlist based off of the interface slot that was interacte with. + * If the interface slot is odd that means that the "+" button was clicked, not right-click add song. + * Song indices are stored in varbits 7081 - 7092 + * + * @param interfaceSlot: The slot number of the interface that was clicked + */ +fun Player.addSongToPlaylist(interfaceSlot: Int) { + var slot = interfaceSlot + if (slot % 2 != 0) slot -= 1 + + val trackIndex = slot / 2 + val playlistVarbit = (7081..7092).first { getVarbit(it) == 32767 } + setVarbit(playlistVarbit, trackIndex) +} + +/** + * Remove a song from the [Player]s play based on the interface slot in either the main track list. + * If [fromTrackList] is true, we need to figure out which varbit that song is in, otherwise we can just + * use [interfaceSlot] to determine the varbit we need to remove. All songs in the following varbits are + * moved back into the previous varbit. + * + * @param interfaceSlot: The slot number of the interface that was clicked + * @param fromTrackList: Whether the clicked slot was on the main track list or on the playlist interface + */ +fun Player.removeSongFromPlaylist( + interfaceSlot: Int, + fromTrackList: Boolean = false, +) { + var playlistSlot = interfaceSlot + + if (fromTrackList) { + if (playlistSlot % 2 != 0) playlistSlot -= 1 + playlistSlot /= 2 + playlistSlot = (7081..7092).indexOfFirst { getVarbit(it) == playlistSlot } + } else { + if (playlistSlot > 11) playlistSlot -= 12 + } + (playlistSlot..11).forEach { + if (it == 11) { + setVarbit(7081 + it, -1) + return@forEach + } + setVarbit(7081 + it, getVarbit(7081 + it + 1)) + } +} + +/** + * Sets all the [Player]s playlist varbits to -1 (Underflows to 32767) + */ +fun Player.clearPlaylist() { + (7081..7092).forEach { + setVarbit(it, -1) + } +} + +/** + * Flips the [Player]s playlist varbit to 0 or 1 + */ +fun Player.togglePlaylist() { + setVarbit(7078, getVarbit(7078) + 1) // value of 2 overflows back to 0 +} + +/** + * Flips the [Player]s playlist shuffle varbit to 0 or 1 + */ +fun Player.togglePlaylistShuffle() { + setVarbit(7079, getVarbit(7079) + 1) // value of 2 overflows back to 0 } fun Player.getVarp(id: Int): Int = varps.getState(id) diff --git a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/cmd/commands.plugin.kts b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/cmd/commands.plugin.kts index d527eb242..1829d8a33 100644 --- a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/cmd/commands.plugin.kts +++ b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/cmd/commands.plugin.kts @@ -19,6 +19,7 @@ import gg.rsmod.plugins.content.inter.bank.openBank import gg.rsmod.plugins.content.magic.TeleportType import gg.rsmod.plugins.content.magic.teleport import gg.rsmod.plugins.content.mechanics.multi.MultiService +import gg.rsmod.plugins.content.mechanics.music.RegionMusicService import gg.rsmod.plugins.content.npcs.Constants import gg.rsmod.plugins.content.skills.farming.core.FarmTicker import gg.rsmod.plugins.content.skills.farming.data.SeedType @@ -1465,6 +1466,32 @@ on_command("getvarc", Privilege.ADMIN_POWER) { } } +on_command("unlocksong", Privilege.ADMIN_POWER) { + val args = player.getCommandArgs() + tryWithUsage( + player, + args, + "Invalid format! Example of proper command ::unlocksong 83", + ) { values -> + val trackId = values[0].toInt() + player.unlockSong(trackId) + } +} + +on_command("resettracks", Privilege.ADMIN_POWER) { + world.getService(RegionMusicService::class.java)?.musicTrackVarps?.forEach { + if (it == -1) return@forEach + player.setVarp(it, 0) + } +} + +on_command("unlockalltracks", Privilege.ADMIN_POWER) { + world.getService(RegionMusicService::class.java)?.musicTrackVarps?.forEach { + if (it == -1) return@forEach + player.setVarp(it, -1) + } +} + fun displayKillCounts( player: Player, killCounts: Map, diff --git a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/RegionMusicService.kt b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/RegionMusicService.kt index bcee02df1..7aa7c5798 100644 --- a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/RegionMusicService.kt +++ b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/RegionMusicService.kt @@ -3,6 +3,7 @@ package gg.rsmod.plugins.content.mechanics.music import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import gg.rsmod.game.Server +import gg.rsmod.game.fs.def.EnumDef import gg.rsmod.game.model.World import gg.rsmod.game.service.Service import gg.rsmod.util.ServerProperties @@ -13,11 +14,48 @@ import java.io.FileNotFoundException class RegionMusicService : Service { val musicTrackList = ObjectArrayList() + val musicTrackVarps = + arrayOf( + 20, + 21, + 22, + 23, + 24, + 25, + 298, + 311, + 346, + 414, + 464, + 598, + 662, + 721, + 906, + 1009, + 1104, + 1136, + 1180, + 1202, + 1381, + 1394, + 1434, + 1596, + 1618, + 1619, + 1620, + -1, // Adding -1 here because songs 864 - 895 are unused + 1864, + 1865, + 2019, + 2246, + ) data class MusicTrack( val name: String, val index: Int, val areas: List, + val varp: Int, + val bitNum: Int, ) data class MusicTrackArea( @@ -40,6 +78,8 @@ class RegionMusicService : Service { val rawMusic = ObjectMapper(YAMLFactory()).readValue(reader, Map::class.java) rawMusic.forEach { (key, value) -> val index = (value as Map<*, *>).getOrDefault("index", -1) as Int + val varp = musicTrackVarps[index.floorDiv(32)] + val bitNum = index - index.floorDiv(32) * 32 val areas = when (val areasValue = value["areas"]) { is List<*> -> { @@ -64,10 +104,30 @@ class RegionMusicService : Service { } else -> emptyList() } - val track = MusicTrack(name = key as String, index = index, areas = areas) + val track = MusicTrack(name = key as String, index = index, areas = areas, varp = varp, bitNum = bitNum) musicTrackList.add(track) } } + // Adding tracks that aren't present in the music file but are in the cache + world.definitions + .get( + EnumDef::class.java, + 1345, + ).values + .filter { it.key !in musicTrackList.map { it.index } } + .forEach { + val varp = musicTrackVarps[it.key.floorDiv(32)] + val bitNum = it.key - it.key.floorDiv(32) * 32 + val track = + MusicTrack( + name = it.value as String, + index = it.key, + areas = listOf(), + varp = varp, + bitNum = bitNum, + ) + musicTrackList.add(track) + } } override fun postLoad( diff --git a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/region_songs.plugin.kts b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/region_songs.plugin.kts index 52378013a..87b2fe411 100644 --- a/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/region_songs.plugin.kts +++ b/game/plugins/src/main/kotlin/gg/rsmod/plugins/content/mechanics/music/region_songs.plugin.kts @@ -1,22 +1,257 @@ package gg.rsmod.plugins.content.mechanics.music +import gg.rsmod.game.model.attr.INTERACTING_ITEM_SLOT +import gg.rsmod.game.model.attr.LAST_SONG_END +import gg.rsmod.game.model.attr.OTHER_ITEM_SLOT_ATTR + /** * @author Alycia * @author vl1 + * @author Ilwyd */ load_service(RegionMusicService()) +val REMOVE_FROM_PLAYLIST_2 = 2 +val PLAY_SONG = 3 +val ADD_TO_PLAYLIST = 4 +val REMOVE_FROM_PLAYLIST_5 = 5 + +val PLAYLIST_VARBITS = 7081..7092 +val PLAYLIST_ENABLED = 7078 +val PLAYLIST_SHUFFLE_ENABLED = 7079 + on_world_init { world.getService(RegionMusicService::class.java)!!.let { service -> service.musicTrackList.forEach { music -> music.areas.forEach { area -> + val id = getTrackIdFromIndex(music.index) + val name = getTrackNameFromIndex(music.index) + val polygonVertices = mutableListOf() + for (i in area.x.indices) polygonVertices.add(Tile(area.x[i], area.y[i])) on_enter_region(regionId = area.region) { - val id = world.definitions.get(EnumDef::class.java, 1351).getInt(music.index) - val name = world.definitions.get(EnumDef::class.java, 1345).getString(music.index) + player.unlockSong(music.index) + val playlistEnabled = player.getVarbit(PLAYLIST_ENABLED) == 1 + if (playlistEnabled) return@on_enter_region + player.playSong(id, name) } + + if (polygonVertices.size != 0) { + on_enter_simple_polygon_area(SimplePolygonArea(polygonVertices.toTypedArray())) { + player.unlockSong(music.index) + val playlistEnabled = player.getVarbit(PLAYLIST_ENABLED) == 1 + if (playlistEnabled) return@on_enter_simple_polygon_area + + player.playSong(id, name) + } + } } } } } + +on_button(187, 1) { + val slot = player.getInteractingSlot() + val trackIndex = slot / 2 + val option = player.getInteractingOption() + val trackId = getTrackIdFromIndex(trackIndex) + val trackName = getTrackNameFromIndex(trackIndex) + + when (option) { + PLAY_SONG -> player.playSong(trackId, trackName) + ADD_TO_PLAYLIST -> player.addSongToPlaylist(slot) + REMOVE_FROM_PLAYLIST_5 -> player.removeSongFromPlaylist(slot, true) + } +} + +on_button(187, 9) { + val trackSlot = player.getInteractingSlot() + val option = player.getInteractingOption() + val trackIndex = player.getVarbit(PLAYLIST_VARBITS.first + trackSlot) + val trackId = getTrackIdFromIndex(trackIndex) + val trackName = getTrackNameFromIndex(trackIndex) + + when (option) { + PLAY_SONG -> player.playSong(trackId, trackName) + REMOVE_FROM_PLAYLIST_2 -> player.removeSongFromPlaylist(trackSlot) + } +} + +on_button(187, 11) { + player.clearPlaylist() +} + +on_button(187, 10) { + player.togglePlaylist() +} + +on_button(187, 13) { + player.togglePlaylistShuffle() +} + +on_component_to_component_item_swap(187, 9, 187, 9) { + val fromSlot = player.attr[INTERACTING_ITEM_SLOT]!! + var toSlot = player.attr[OTHER_ITEM_SLOT_ATTR]!! + + // Converting the toSlot number to align with the button slots + if (toSlot <= -16) { + toSlot += 16 + } + toSlot += 12 + + val trackFrom = player.getVarbit(PLAYLIST_VARBITS.first + fromSlot) + val trackTo = player.getVarbit(PLAYLIST_VARBITS.first + toSlot) + + player.setVarbit(PLAYLIST_VARBITS.first + toSlot, trackFrom) + player.setVarbit(PLAYLIST_VARBITS.first + fromSlot, trackTo) +} + +on_song_end { + val finishedSongId = player.attr[LAST_SONG_END]!! + val finishedSongIndex = getTrackIndexFromId(finishedSongId) + val playlistEnabled = player.getVarbit(PLAYLIST_ENABLED) == 1 + val shuffleEnabled = player.getVarbit(PLAYLIST_SHUFFLE_ENABLED) == 1 + + val songIndex: Int + val playlistSongs = PLAYLIST_VARBITS.filter { player.getVarbit(it) != 32767 }.map { player.getVarbit(it) } + + if (playlistEnabled && shuffleEnabled) { + // If the playlist is enabled and shuffle is enabled, play a random song from the playlist + songIndex = playlistSongs[world.random(playlistSongs.size - 1)] + } else if (playlistEnabled) { + // If the playlist is enabled, but shuffle is not, play the next song in the list + songIndex = playlistSongs[(playlistSongs.indexOf(finishedSongIndex) + 1) % 12] + } else { + // If the playlist is off, find out if we're in any region / area that has any associated songs and play a + // random one + val musicTracks = world.getService(RegionMusicService::class.java)?.musicTrackList + val songsToChoose = mutableListOf() + musicTracks?.forEach { musicTrack -> + musicTrack.areas.forEach { + val vertices = (it.x zip it.y).map { pos -> Tile(pos.first, pos.second) } + val playerInRegion = it.region == player.tile.regionId + val playerInPolygonArea = + vertices.isNotEmpty() && SimplePolygonArea(vertices.toTypedArray()).containsTile(player.tile) + if (playerInRegion || playerInPolygonArea) { + songsToChoose.add(musicTrack) + } + } + } + + if (songsToChoose.size == 0) return@on_song_end + + songIndex = songsToChoose[world.random(songsToChoose.size - 1)].index + } + + val songId = getTrackIdFromIndex(songIndex) + val songName = getTrackNameFromIndex(songIndex) + player.playSong(songId, songName) +} + +/** + * When the player logs in we need to set the interface events for the main music tab and the playlist tab. + * Setting is larger for the playlist tab because we need to enable dragging the playlist items. + */ +on_login { + // Enabling clicking music in main tab + player.setEvents(interfaceId = 187, component = 1, to = 2030, setting = 30) + + // Enabling clicking music in the playlist tab + player.setEvents(interfaceId = 187, component = 9, to = 23, setting = 0x24001E) +} + +/** + * When the play logs in we need to unlock all of the default songs if they haven't already been + */ +on_login { + val defaultTracks = // Taken from https://runescape.wiki/w/List_of_music_tracks + arrayOf( + 0, + 5, + 9, + 17, + 23, + 35, + 47, + 48, + 52, + 60, + 63, + 84, + 89, + 90, + 93, + 103, + 105, + 106, + 110, + 113, + 114, + 131, + 143, + 145, + 146, + 150, + 151, + 152, + 153, + 159, + 160, + 161, + 163, + 165, + 166, + 170, + 175, + 185, + 188, + 190, + 191, + 196, + 200, + 257, + 316, + 318, + 321, + 323, + 336, + 340, + 341, + 350, + 360, + 361, + 377, + 411, + 412, + 479, + 482, + 514, + 517, + 518, + 519, + 520, + 555, + 602, + 611, + 650, + 717, + 931, + ) + + defaultTracks.forEach { + player.unlockSong(it, false) + } +} + +fun getTrackIdFromIndex(songIndex: Int): Int { + return world.definitions.get(EnumDef::class.java, 1351).getInt(songIndex) +} + +fun getTrackNameFromIndex(songIndex: Int): String { + return world.definitions.get(EnumDef::class.java, 1345).getString(songIndex) +} + +fun getTrackIndexFromId(songId: Int): Int { + return world.definitions.get(EnumDef::class.java, 1351).getKeyForValue(songId) +} diff --git a/game/src/main/kotlin/gg/rsmod/game/message/MessageDecoderSet.kt b/game/src/main/kotlin/gg/rsmod/game/message/MessageDecoderSet.kt index c09d8f19f..1f97eba7c 100644 --- a/game/src/main/kotlin/gg/rsmod/game/message/MessageDecoderSet.kt +++ b/game/src/main/kotlin/gg/rsmod/game/message/MessageDecoderSet.kt @@ -65,6 +65,7 @@ class MessageDecoderSet { put(WorldMapCloseMessage::class.java, WorldMapCloseDecoder(), WorldMapCloseHandler(), structures) put(KeyTypedMessage::class.java, KeyTypedDecoder(), KeyTypedHandler(), structures) + put(SoundSongEndMessage::class.java, SoundSongEndDecoder(), SoundSongEndHandler(), structures) } private fun put( diff --git a/game/src/main/kotlin/gg/rsmod/game/message/decoder/SoundSongEndDecoder.kt b/game/src/main/kotlin/gg/rsmod/game/message/decoder/SoundSongEndDecoder.kt new file mode 100644 index 000000000..8b8e15d4f --- /dev/null +++ b/game/src/main/kotlin/gg/rsmod/game/message/decoder/SoundSongEndDecoder.kt @@ -0,0 +1,16 @@ +package gg.rsmod.game.message.decoder + +import gg.rsmod.game.message.MessageDecoder +import gg.rsmod.game.message.impl.SoundSongEndMessage + +class SoundSongEndDecoder : MessageDecoder() { + override fun decode( + opcode: Int, + opcodeIndex: Int, + values: HashMap, + stringValues: HashMap, + ): SoundSongEndMessage { + val songId = values["songId"]!! + return SoundSongEndMessage(songId.toInt()) + } +} diff --git a/game/src/main/kotlin/gg/rsmod/game/message/handler/SoundSongEndHandler.kt b/game/src/main/kotlin/gg/rsmod/game/message/handler/SoundSongEndHandler.kt new file mode 100644 index 000000000..009ca2f5a --- /dev/null +++ b/game/src/main/kotlin/gg/rsmod/game/message/handler/SoundSongEndHandler.kt @@ -0,0 +1,18 @@ +package gg.rsmod.game.message.handler + +import gg.rsmod.game.message.MessageHandler +import gg.rsmod.game.message.impl.SoundSongEndMessage +import gg.rsmod.game.model.World +import gg.rsmod.game.model.attr.LAST_SONG_END +import gg.rsmod.game.model.entity.Client + +class SoundSongEndHandler : MessageHandler { + override fun handle( + client: Client, + world: World, + message: SoundSongEndMessage, + ) { + client.attr[LAST_SONG_END] = message.songId + world.plugins.executeSoundSongEnd(client) + } +} diff --git a/game/src/main/kotlin/gg/rsmod/game/message/impl/SoundSongEndMessage.kt b/game/src/main/kotlin/gg/rsmod/game/message/impl/SoundSongEndMessage.kt new file mode 100644 index 000000000..00e3d4567 --- /dev/null +++ b/game/src/main/kotlin/gg/rsmod/game/message/impl/SoundSongEndMessage.kt @@ -0,0 +1,7 @@ +package gg.rsmod.game.message.impl + +import gg.rsmod.game.message.Message + +data class SoundSongEndMessage( + val songId: Int, +) : Message diff --git a/game/src/main/kotlin/gg/rsmod/game/model/SimplePolygonArea.kt b/game/src/main/kotlin/gg/rsmod/game/model/SimplePolygonArea.kt new file mode 100644 index 000000000..8719db5e9 --- /dev/null +++ b/game/src/main/kotlin/gg/rsmod/game/model/SimplePolygonArea.kt @@ -0,0 +1,97 @@ +package gg.rsmod.game.model + +/** + * Represents a simple polygon area in the world. Simple polygons are polygons which have no overlapping sides and no + * inner gaps. + * + * @author Ilwyd + */ +data class SimplePolygonArea( + var vertices: Array, +) { + val associatedRegionIds: Array = determineRegions() + + /** + * Determines whether a [Tile] is within the [SimplePolygonArea] or not. Because this is a simple polygon, we can + * use the Even-Odd Rule (https://en.wikipedia.org/wiki/Even%E2%80%93odd_rule) to determine if the tile is in the + * polygon. + */ + fun containsTile(tile: Tile): Boolean { + var inPolygon = false + for (i in vertices.indices) { + val a = vertices[i] + val b = vertices[(i - 1 + vertices.size) % vertices.size] + + // Tile is on a corner + if (tile.x == a.x && tile.z == a.z) return true + + if ((a.z > tile.z) != (b.z > tile.z)) { + val slope = (tile.x - a.x) * (b.z - a.z) - (b.x - a.x) * (tile.z - a.z) + // Tile is on boundary + if (slope == 0) return true + if ((slope < 0) != (b.z < a.z)) inPolygon = !inPolygon + } + } + return inPolygon + } + + /** + * Determines which regions contain tiles of this polygon area. Currently, this works by getting the highest and + * lowest x and z tile values in the vertices and getting a rectangle of regions. This is not the most efficient + * way to go about this, but is fast enough for our purposes at the moment. + */ + private fun determineRegions(): Array { + // If we only have 2 vertices, we need to turn it into a box + if (vertices.size == 2) { + val vertList = vertices.toMutableList() + vertList.add(1, Tile(vertices[0].x, vertices[1].z)) + vertList.add(3, Tile(vertices[1].x, vertices[0].z)) + + vertices = vertList.toTypedArray() + } + + val maxX = vertices.maxOf { it.x } + val minX = vertices.minOf { it.x } + val maxZ = vertices.maxOf { it.z } + val minZ = vertices.minOf { it.z } + + val topRight = Tile(maxX, maxZ) + val bottomLeft = Tile(minX, minZ) + + val topRightRegion = topRight.regionId + val bottomLeftRegion = bottomLeft.regionId + + if (topRightRegion == bottomLeftRegion) return arrayOf(topRightRegion) + + val regionDiffZ = (topRightRegion - bottomLeftRegion) % 256 + val regionDiffX = (topRightRegion - bottomLeftRegion - regionDiffZ) / 256 + + val output = mutableListOf() + + for (z in 0..regionDiffZ) { + for (x in 0..regionDiffX) { + output.add(bottomLeftRegion + z + (x * 256)) + } + } + + return output.toTypedArray() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SimplePolygonArea + + if (!vertices.contentEquals(other.vertices)) return false + if (!associatedRegionIds.contentEquals(other.associatedRegionIds)) return false + + return true + } + + override fun hashCode(): Int { + var result = vertices.contentHashCode() + result = 31 * result + associatedRegionIds.contentHashCode() + return result + } +} diff --git a/game/src/main/kotlin/gg/rsmod/game/model/attr/Attributes.kt b/game/src/main/kotlin/gg/rsmod/game/model/attr/Attributes.kt index f03c32e38..2afc3051c 100644 --- a/game/src/main/kotlin/gg/rsmod/game/model/attr/Attributes.kt +++ b/game/src/main/kotlin/gg/rsmod/game/model/attr/Attributes.kt @@ -539,3 +539,8 @@ val HAS_SPAWNED_TREE_SPIRIT = AttributeKey() * Since keys are always strings, we must have our key as a String here too */ val NPC_KILL_COUNTS = AttributeKey>(persistenceKey = "npc_kill_counts") + +/** + * The ID of the last song that ended + */ +val LAST_SONG_END = AttributeKey() diff --git a/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPlugin.kt b/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPlugin.kt index 5732ea5cc..a9cc05424 100644 --- a/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPlugin.kt +++ b/game/src/main/kotlin/gg/rsmod/game/plugin/KotlinPlugin.kt @@ -7,6 +7,7 @@ import gg.rsmod.game.fs.def.ItemDef import gg.rsmod.game.fs.def.NpcDef import gg.rsmod.game.fs.def.ObjectDef import gg.rsmod.game.model.Direction +import gg.rsmod.game.model.SimplePolygonArea import gg.rsmod.game.model.Tile import gg.rsmod.game.model.World import gg.rsmod.game.model.combat.NpcCombatDef @@ -786,6 +787,19 @@ abstract class KotlinPlugin( logic: (Plugin).() -> Unit, ) = r.bindChunkEnter(chunkHash, logic) + /** + * Invoke [logic] when a player enters a [SimplePolygonArea] + */ + fun on_enter_simple_polygon_area( + area: SimplePolygonArea, + logic: Plugin.() -> Unit, + ) = r.bindSimplePolygonAreaEnter(area, logic) + + /** + * Invoke [logic] when a song ends for a player + */ + fun on_song_end(logic: Plugin.() -> Unit) = r.bindSoundSongEnd(logic) + /** * Invoke [logic] when a player exits a chunk (8x8 Tiles). */ diff --git a/game/src/main/kotlin/gg/rsmod/game/plugin/PluginRepository.kt b/game/src/main/kotlin/gg/rsmod/game/plugin/PluginRepository.kt index ffbe0f69a..250f91bde 100644 --- a/game/src/main/kotlin/gg/rsmod/game/plugin/PluginRepository.kt +++ b/game/src/main/kotlin/gg/rsmod/game/plugin/PluginRepository.kt @@ -4,6 +4,7 @@ import com.google.common.collect.HashMultimap import com.google.common.collect.Multimap import gg.rsmod.game.Server import gg.rsmod.game.event.Event +import gg.rsmod.game.model.SimplePolygonArea import gg.rsmod.game.model.World import gg.rsmod.game.model.attr.COMMAND_ARGS_ATTR import gg.rsmod.game.model.attr.COMMAND_ATTR @@ -213,6 +214,18 @@ class PluginRepository( */ private val enterChunkPlugins = Int2ObjectOpenHashMap Unit>>() + /** + * A map that contains any plugin that will be executed upon entering a new + * [gg.rsmod.game.model.SimplePolygonArea]. The key is the area hash, which can be + * calculated via [gg.rsmod.game.model.SimplePolygonArea.hashCode]. + */ + private val enterSimplePolygonAreaPlugins = Int2ObjectOpenHashMap Unit>>() + + /** + * A map that contains any plugins that will be executed when a song ends on the client + */ + private val songEndPlugins = mutableListOf<(Plugin.() -> Unit)>() + /** * A map that contains any plugin that will be executed when leaving a * [gg.rsmod.game.model.region.Chunk]. The key is the chunk id which can be @@ -424,6 +437,11 @@ class PluginRepository( */ internal val services = mutableListOf() + /** + * A list of [SimplePolygonArea]s that have been set for plugins. + */ + internal val simplePolygonAreas = mutableListOf() + /** * Holds all container keys set from plugins for this [PluginRepository]. */ @@ -1281,6 +1299,36 @@ class PluginRepository( pluginCount++ } + fun bindSimplePolygonAreaEnter( + area: SimplePolygonArea, + plugin: Plugin.() -> Unit, + ) { + val areaHash = area.hashCode() + val plugins = enterSimplePolygonAreaPlugins[areaHash] + simplePolygonAreas.add(area) + if (plugins != null) { + plugins.add(plugin) + } else { + enterSimplePolygonAreaPlugins[areaHash] = arrayListOf(plugin) + } + pluginCount++ + } + + fun executeSimplePolygonAreaEnter( + p: Player, + areaHash: Int, + ) { + enterSimplePolygonAreaPlugins[areaHash]?.forEach { logic -> p.executePlugin(logic) } + } + + fun bindSoundSongEnd(plugin: Plugin.() -> Unit) { + songEndPlugins.add(plugin) + } + + fun executeSoundSongEnd(p: Player) { + songEndPlugins.forEach { logic -> p.executePlugin(logic) } + } + fun executeChunkEnter( p: Player, chunkHash: Int, diff --git a/game/src/main/kotlin/gg/rsmod/game/sync/task/PlayerPostSynchronizationTask.kt b/game/src/main/kotlin/gg/rsmod/game/sync/task/PlayerPostSynchronizationTask.kt index b676e3799..fd88e6ea9 100644 --- a/game/src/main/kotlin/gg/rsmod/game/sync/task/PlayerPostSynchronizationTask.kt +++ b/game/src/main/kotlin/gg/rsmod/game/sync/task/PlayerPostSynchronizationTask.kt @@ -51,6 +51,11 @@ object PlayerPostSynchronizationTask : SynchronizationTask { pawn.world.plugins.executeChunkEnter(pawn, newChunk.coords.hashCode()) } } + pawn.world.plugins.simplePolygonAreas.forEach { + if (pawn.tile.regionId in it.associatedRegionIds && it.containsTile(pawn.tile)) { + pawn.world.plugins.executeSimplePolygonAreaEnter(pawn, it.hashCode()) + } + } } } }