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())
+ }
+ }
}
}
}