From 8cb7cc47d3958379eadc2107c7e1c6201f036797 Mon Sep 17 00:00:00 2001 From: MouldyToast <53084981+MouldyToast@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:59:11 +0800 Subject: [PATCH 1/4] Modify bank access and admin commands, allowing the bank to resize when in resizable mode --- .../main/kotlin/org/rsmod/content/interfaces/bank/BankAccess.kt | 2 +- .../kotlin/org/rsmod/content/other/commands/AdminCommands.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/interfaces/bank/src/main/kotlin/org/rsmod/content/interfaces/bank/BankAccess.kt b/content/interfaces/bank/src/main/kotlin/org/rsmod/content/interfaces/bank/BankAccess.kt index a62fdc2e3..1385fb8c9 100644 --- a/content/interfaces/bank/src/main/kotlin/org/rsmod/content/interfaces/bank/BankAccess.kt +++ b/content/interfaces/bank/src/main/kotlin/org/rsmod/content/interfaces/bank/BankAccess.kt @@ -7,6 +7,6 @@ fun ProtectedAccess.tryOpenBank(): Boolean { if (IronmanRestrictions.blockUimBank(player)) { return false } - ifOpenMainSidePair(main = "interface.bankmain", side = "interface.bankside") + ifOpenMainSidePair(main = "interface.bankmain", side = "interface.bankside", transparency = -2) return true } diff --git a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt index 056b457ae..4875f6909 100644 --- a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt +++ b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt @@ -654,7 +654,7 @@ constructor( private fun bank(cheat: Cheat) = with(cheat) { protectedAccess.launch(player) { - ifOpenMainSidePair(main = "interface.bankmain", side = "interface.bankside") + ifOpenMainSidePair(main = "interface.bankmain", side = "interface.bankside", transparency = -2) } } From 910a6a5e624397bd45d05d8e091fae155c6857a6 Mon Sep 17 00:00:00 2001 From: MouldyToast <53084981+MouldyToast@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:12:01 +0800 Subject: [PATCH 2/4] Add world entity engine support (sailing phases 1-2c) --- .../player/PlayerZoneUpdateProcessorTest.kt | 5 +- .../player/PlayerBuildAreaProcessor.kt | 18 +- .../player/PlayerZoneUpdateProcessor.kt | 42 ++--- api/net/build.gradle.kts | 1 + .../org/rsmod/api/net/rsprot/NetworkScript.kt | 57 +++++- .../org/rsmod/api/net/rsprot/RspCycle.kt | 155 +++++++++++++++ .../api/net/rsprot/RspWorldEntityInfo.kt | 26 +++ .../worldentity/WorldEntityRegistry.kt | 43 +++++ .../worldentity/WorldEntityRegistryResult.kt | 34 ++++ .../registry/zone/ZoneUpdateTransformer.kt | 28 +++ .../commands/AdminWorldEntityDebugCommands.kt | 177 ++++++++++++++++++ .../kotlin/org/rsmod/game/entity/Player.kt | 8 + .../org/rsmod/game/entity/WorldEntity.kt | 120 ++++++++++++ .../org/rsmod/game/entity/WorldEntityList.kt | 22 +++ .../worldentity/WorldEntityInfoProtocol.kt | 25 +++ .../worldentity/WorldEntityStateEvents.kt | 10 + .../rsmod/server/app/modules/GameModule.kt | 2 + 17 files changed, 742 insertions(+), 31 deletions(-) create mode 100644 api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspWorldEntityInfo.kt create mode 100644 api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistry.kt create mode 100644 api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistryResult.kt create mode 100644 content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt create mode 100644 engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntity.kt create mode 100644 engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntityList.kt create mode 100644 engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityInfoProtocol.kt create mode 100644 engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityStateEvents.kt diff --git a/api/game-process/src/integration/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessorTest.kt b/api/game-process/src/integration/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessorTest.kt index f6d66e108..e6ab5563d 100644 --- a/api/game-process/src/integration/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessorTest.kt +++ b/api/game-process/src/integration/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessorTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test import org.rsmod.api.game.process.player.PlayerZoneUpdateProcessor.Companion.ZONE_VIEW_RADIUS import org.rsmod.api.registry.loc.LocRegistry import org.rsmod.api.registry.loc.LocRegistryNormal +import org.rsmod.game.entity.WorldEntityList import org.rsmod.api.registry.obj.ObjRegistry import org.rsmod.api.registry.zone.ZoneUpdateMap import org.rsmod.api.testing.GameTestState @@ -47,7 +48,7 @@ class PlayerZoneUpdateProcessorTest { @Test fun GameTestState.`process zones in a new build area`() = runAdvancedGameTest { val parameters = createZoneProcess() - val buildProcessor = PlayerBuildAreaProcessor() + val buildProcessor = PlayerBuildAreaProcessor(WorldEntityList()) val zoneProcessor = parameters.zoneProcessor val locZones = parameters.locZones val locRegistry = parameters.normalLocReg @@ -148,7 +149,7 @@ class PlayerZoneUpdateProcessorTest { @Test fun GameTestState.`only send private obj to receiver`() = runAdvancedGameTest { val parameters = createZoneProcess() - val buildProcessor = PlayerBuildAreaProcessor() + val buildProcessor = PlayerBuildAreaProcessor(WorldEntityList()) val zoneProcessor = parameters.zoneProcessor val zoneUpdateMap = parameters.zoneUpdateMap val objRegistry = parameters.objRegistry diff --git a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerBuildAreaProcessor.kt b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerBuildAreaProcessor.kt index 77e2e52c1..4a687a368 100644 --- a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerBuildAreaProcessor.kt +++ b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerBuildAreaProcessor.kt @@ -1,15 +1,31 @@ package org.rsmod.api.game.process.player +import jakarta.inject.Inject import org.rsmod.api.utils.map.BuildAreaUtils import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntityList import org.rsmod.map.zone.ZoneKey -public class PlayerBuildAreaProcessor { +public class PlayerBuildAreaProcessor +@Inject +constructor(private val worldEntities: WorldEntityList) { public fun process(player: Player) { player.processBuildAreaChange() } private fun Player.processBuildAreaChange() { + // Players aboard a world entity keep the root build area anchored to the entity's + // root-world position: their own coords are instance-land coords, but the client's + // root map must follow the boat. No rebuild on embark; recentre only when the + // entity nears the build-area boundary (mid-sail recentre). + val worldEntity = worldEntities.findAt(coords) + if (worldEntity != null) { + val entityCoords = worldEntity.coords + if (BuildAreaUtils.isOutsideOfBuildArea(entityCoords, buildArea)) { + buildArea = BuildAreaUtils.calculateBuildArea(ZoneKey.from(entityCoords)) + } + return + } val rebuildBuildArea = BuildAreaUtils.requiresNewBuildArea(this) if (rebuildBuildArea) { enterBuildArea() diff --git a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessor.kt b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessor.kt index 7663c59a2..6e3f9dbbd 100644 --- a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessor.kt +++ b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerZoneUpdateProcessor.kt @@ -15,6 +15,7 @@ import org.rsmod.api.registry.zone.ZoneUpdateTransformer import org.rsmod.api.utils.map.BuildAreaUtils import org.rsmod.api.utils.zone.SharedZoneEnclosedBuffers import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.loc.LocInfo import org.rsmod.game.obj.Obj import org.rsmod.map.CoordGrid @@ -27,6 +28,7 @@ constructor( private val locReg: LocRegistry, private val objReg: ObjRegistry, private val enclosedBuffers: SharedZoneEnclosedBuffers, + private val worldEntities: WorldEntityList, ) { public fun computeEnclosedBuffers() { enclosedBuffers.computeSharedBuffers() @@ -45,13 +47,17 @@ constructor( } private fun Player.processZoneUpdates() { - val currZone = ZoneKey.from(coords) + // The root-world view center is normally the player's own zone. While aboard a world + // entity, the player's coords are instance-land coords, but their root-world view + // follows the entity's root position - center the visible zones there instead. + val viewCoords = worldEntities.findAt(coords)?.coords ?: coords + val currZone = ZoneKey.from(viewCoords) val visibleZones = visibleZoneKeys - val prevZone = lastProcessedZone + val prevZone = lastProcessedViewZone val buildArea = buildArea if (currZone != prevZone) { - // Compute neighbouring zones based on the player's current zone. + // Compute neighbouring zones based on the player's current view zone. val currZones = currZone.computeVisibleNeighbouringZones().filterWithinBuildArea(buildArea) @@ -76,7 +82,10 @@ constructor( processVisibleZoneUpdates(buildArea, visibleZones) } - lastProcessedZone = currZone + lastProcessedViewZone = currZone + // `lastProcessedZone` must keep tracking the player's REAL zone: zone occupancy + // (PlayerMapUpdateProcessor -> playerRegistry.change) relies on it. + lastProcessedZone = ZoneKey.from(coords) } private fun Player.processNewVisibleZones(buildArea: CoordGrid, zones: IntList) { @@ -183,29 +192,8 @@ constructor( client.write(prot) } - private fun Iterable.toPlayerSpecificEnclosed(observerId: Long?): List { - val enclosed = ArrayList() - for (update in this) { - val prot = - (update as? ZoneUpdateTransformer.PartialFollowsZoneProt) - ?.toEnclosed(observerId) - if (prot != null) { - enclosed += prot - } - } - return enclosed - } - - private fun ZoneUpdateTransformer.PartialFollowsZoneProt.toEnclosed( - observerId: Long?, - ): ZoneProt? = - when (this) { - is ZoneUpdateTransformer.ObjPrivateZoneProt -> - if (isVisibleTo(observerId)) backing else null - is ZoneUpdateTransformer.ObjReveal -> - if (observerId == obj.receiverId) null else backing - else -> backing - } + private fun Iterable.toPlayerSpecificEnclosed(observerId: Long?): List = + ZoneUpdateTransformer.toObserverEnclosedProtList(this, observerId) private fun ZoneKey.computeVisibleNeighbouringZones(): IntList { val zones = IntArrayList(ZONE_VIEW_TOTAL_COUNT) diff --git a/api/net/build.gradle.kts b/api/net/build.gradle.kts index e5f503b7d..9d6e64a28 100644 --- a/api/net/build.gradle.kts +++ b/api/net/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.api.script) implementation(projects.api.serverConfig) implementation(projects.api.totp) + implementation(projects.api.utils.utilsZone) implementation(projects.engine.annotations) implementation(projects.engine.coroutine) implementation(projects.engine.events) diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/NetworkScript.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/NetworkScript.kt index b3e8cc9b3..a08a00c23 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/NetworkScript.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/NetworkScript.kt @@ -12,17 +12,24 @@ import org.rsmod.api.net.central.OpenRuneCentralWorldLink import org.rsmod.api.net.central.logging.CentralActivityLogWriter import org.rsmod.api.net.rsprot.player.SessionStart import org.rsmod.api.player.events.interact.HeldDropEvents +import org.rsmod.api.registry.loc.LocRegistry +import org.rsmod.api.registry.obj.ObjRegistry import org.rsmod.api.registry.player.PlayerRegistry import org.rsmod.api.registry.region.RegionRegistry +import org.rsmod.api.registry.zone.ZoneUpdateMap import org.rsmod.api.script.onEvent import org.rsmod.api.server.config.ServerConfig +import org.rsmod.api.utils.zone.SharedZoneEnclosedBuffers import org.rsmod.game.GameUpdate import org.rsmod.game.MapClock import org.rsmod.game.client.Client import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.entity.npc.NpcStateEvents import org.rsmod.game.entity.player.SessionStateEvent +import org.rsmod.game.entity.worldentity.WorldEntityStateEvents import org.rsmod.plugin.scripts.PluginScript import org.rsmod.plugin.scripts.ScriptContext @@ -39,6 +46,11 @@ constructor( private val database: GameDatabase, private val characterRepository: CharacterAccountRepository, private val playerRegistry: PlayerRegistry, + private val worldEntityList: WorldEntityList, + private val zoneUpdates: ZoneUpdateMap, + private val locReg: LocRegistry, + private val objReg: ObjRegistry, + private val enclosedBuffers: SharedZoneEnclosedBuffers, private val centralActivityLogWriter: CentralActivityLogWriter, ) : PluginScript() { private val logger = InlineLogger() @@ -83,6 +95,8 @@ constructor( onEvent { notifyCentralLogout() } onEvent { createNpcAvatar(npc) } onEvent { deleteNpcAvatar(npc) } + onEvent { createWorldEntityAvatar(entity) } + onEvent { deleteWorldEntityAvatar(entity) } } private fun initService() { @@ -90,6 +104,9 @@ constructor( } private fun updateService() { + // `infoProtocols.update()` runs worldentity -> player -> npc in order; the npc info + // protocol's first step (`synchronizeModifiedWorlds`) consumes the added/removed + // world entity indices itself - no server-side world sync is needed. service.infoProtocols.update() if (openRuneCentral.isEnabled) { openRuneCentral.drainInboundRevokesOnGameThread(playerRegistry, gameUpdate) @@ -137,7 +154,17 @@ constructor( val infos = service.infoProtocols.alloc(slot, OldSchoolClientType.DESKTOP) val client = RspClient(session, infos) as Client - val cycle = RspCycle(session, infos, regionReg) + val cycle = + RspCycle( + session, + infos, + regionReg, + worldEntityList, + zoneUpdates, + locReg, + objReg, + enclosedBuffers, + ) player.client = client player.clientCycle = cycle @@ -215,4 +242,32 @@ constructor( service.npcAvatarFactory.release(infoProtocol.rspAvatar) } } + + private fun createWorldEntityAvatar(entity: WorldEntity) { + val rspAvatar = + service.worldEntityAvatarFactory.alloc( + index = entity.slotId, + id = entity.id, + ownerIndex = entity.ownerIndex, + sizeX = entity.sizeX, + sizeZ = entity.sizeZ, + southWestZoneX = entity.southWestZoneX, + southWestZoneZ = entity.southWestZoneZ, + minLevel = entity.minLevel, + maxLevel = entity.maxLevel, + fineX = entity.fineX, + fineZ = entity.fineZ, + projectedLevel = entity.projectedLevel, + activeLevel = entity.activeLevel, + angle = entity.angle, + ) + entity.infoProtocol = RspWorldEntityInfo(rspAvatar) + } + + private fun deleteWorldEntityAvatar(entity: WorldEntity) { + val infoProtocol = entity.infoProtocol + if (infoProtocol is RspWorldEntityInfo) { + service.worldEntityAvatarFactory.release(infoProtocol.rspAvatar) + } + } } diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspCycle.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspCycle.kt index b9874ba69..7e3ca1ff9 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspCycle.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspCycle.kt @@ -4,6 +4,7 @@ import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.util.Wearpos import net.rsprot.protocol.api.Session +import net.rsprot.protocol.common.client.OldSchoolClientType import net.rsprot.protocol.game.outgoing.info.Infos import net.rsprot.protocol.game.outgoing.info.npcinfo.NpcInfoPacket import net.rsprot.protocol.game.outgoing.info.playerinfo.PlayerAvatarExtendedInfo @@ -15,15 +16,26 @@ import net.rsprot.protocol.game.outgoing.info.util.safeReleaseOrThrow import net.rsprot.protocol.game.outgoing.map.RebuildLoginV2 import net.rsprot.protocol.game.outgoing.map.RebuildNormalV2 import net.rsprot.protocol.game.outgoing.map.RebuildRegionV2 +import net.rsprot.protocol.game.outgoing.map.RebuildWorldEntityV4 import net.rsprot.protocol.game.outgoing.map.util.RebuildRegionZone import net.rsprot.protocol.game.outgoing.map.util.ReferenceZone +import net.rsprot.protocol.game.outgoing.zone.header.UpdateZoneFullFollows +import net.rsprot.protocol.game.outgoing.zone.header.UpdateZonePartialEnclosed import net.rsprot.protocol.message.OutgoingGameMessage +import net.rsprot.protocol.message.ZoneProt import org.rsmod.api.config.refs.params import org.rsmod.api.net.rsprot.ext.setFaceTarget import org.rsmod.api.player.righthand +import org.rsmod.api.registry.loc.LocRegistry +import org.rsmod.api.registry.obj.ObjRegistry import org.rsmod.api.registry.region.RegionRegistry +import org.rsmod.api.registry.zone.ZoneUpdateMap +import org.rsmod.api.registry.zone.ZoneUpdateTransformer +import org.rsmod.api.utils.zone.SharedZoneEnclosedBuffers import org.rsmod.game.client.ClientCycle import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.entity.util.EntityFaceAngle import org.rsmod.game.headbar.Headbar import org.rsmod.game.hit.Hitmark @@ -40,6 +52,11 @@ class RspCycle( private val session: Session, private val infos: Infos, private val regions: RegionRegistry, + private val worldEntities: WorldEntityList, + private val zoneUpdates: ZoneUpdateMap, + private val locReg: LocRegistry, + private val objReg: ObjRegistry, + private val enclosedBuffers: SharedZoneEnclosedBuffers, ) : ClientCycle { private var knownCoords: CoordGrid = CoordGrid.ZERO @@ -101,12 +118,135 @@ class RspCycle( for (world in infoPackets.activeWorlds) { session.queue(world.activeWorld) session.queue(world.npcUpdateOrigin) + if (world.added) { + queueRebuildWorldEntity(world.worldId) + } session.queueNpcInfoPacket(world.npcInfo) + queueWorldEntityZoneUpdates(player, world.worldId, world.added) } session.queue(rootPackets.activeWorld) } + private fun queueRebuildWorldEntity(worldId: Int) { + val entity = worldEntities[worldId] ?: return + val rebuild = + RebuildWorldEntityV4( + baseX = entity.southWestZoneX shl 3, + baseZ = entity.southWestZoneZ shl 3, + sizeX = entity.sizeX, + sizeZ = entity.sizeZ, + zoneProvider = createWorldEntityZoneProvider(entity), + ) + session.queue(rebuild) + } + + /** + * Queues zone updates for the deck zones of world entity [worldId]. The caller queues these + * inside the world's flush section (after its npc info), so they are framed under the + * per-world active-world context. On the cycle the world is [added] to this player's high + * resolution, the rebuild restored template state - reset each deck zone and replay its + * persistent deltas (spawned locs, ground objs). On subsequent cycles, forward this tick's + * transient updates. Zone headers are relative to the entity world's build-area base (the + * rebuild baseX/baseZ), with no view-radius shift. + */ + private fun queueWorldEntityZoneUpdates(player: Player, worldId: Int, added: Boolean) { + val entity = worldEntities[worldId] ?: return + val baseX = entity.southWestZoneX shl 3 + val baseZ = entity.southWestZoneZ shl 3 + for (level in entity.minLevel..entity.maxLevel) { + for (zoneX in 0 until entity.sizeX) { + for (zoneZ in 0 until entity.sizeZ) { + val zone = + ZoneKey( + entity.southWestZoneX + zoneX, + entity.southWestZoneZ + zoneZ, + level, + ) + val zoneBase = zone.toCoords() + val deltaX = zoneBase.x - baseX + val deltaZ = zoneBase.z - baseZ + if (added) { + session.queue(UpdateZoneFullFollows(deltaX, deltaZ, level)) + queuePersistentZoneState(player, zone, deltaX, deltaZ, level) + } else { + queueTransientZoneUpdates(player, zone, deltaX, deltaZ, level) + } + } + } + } + } + + private fun queuePersistentZoneState( + player: Player, + zone: ZoneKey, + deltaX: Int, + deltaZ: Int, + level: Int, + ) { + for (loc in locReg.findAllSpawned(zone)) { + session.queue(ZoneUpdateTransformer.toPersistentLocChange(loc)) + } + val objProts = ArrayList() + for (obj in objReg.findAll(zone)) { + val prot = + ZoneUpdateTransformer.toPersistentObjAdd(obj, player.observerUUID) ?: continue + objProts += prot + } + queueEnclosed(deltaX, deltaZ, level, objProts) + } + + private fun queueTransientZoneUpdates( + player: Player, + zone: ZoneKey, + deltaX: Int, + deltaZ: Int, + level: Int, + ) { + val shared = enclosedBuffers[zone]?.get(OldSchoolClientType.DESKTOP) + if (shared != null) { + session.queue(UpdateZonePartialEnclosed(deltaX, deltaZ, level, shared)) + } + val updates = zoneUpdates[zone] ?: return + val playerSpecific = + ZoneUpdateTransformer.toObserverEnclosedProtList(updates, player.observerUUID) + queueEnclosed(deltaX, deltaZ, level, playerSpecific) + } + + private fun queueEnclosed(deltaX: Int, deltaZ: Int, level: Int, prots: List) { + if (prots.isEmpty()) { + return + } + val buffer = enclosedBuffers.computeBufferForClient(OldSchoolClientType.DESKTOP, prots) + session.queue(UpdateZonePartialEnclosed(deltaX, deltaZ, level, buffer)) + } + + private fun createWorldEntityZoneProvider( + entity: WorldEntity + ): RebuildWorldEntityV4.RebuildWorldEntityZoneProvider { + val region = + entity.region + ?: return RebuildWorldEntityV4.RebuildWorldEntityZoneProvider { _, _, _ -> null } + val regionZones = region.toZoneList() + val rebuildZones = regionZones.associateWith { zone -> + val copyZone = regions[zone] + if (copyZone == RegionZoneCopy.NULL) { + return@associateWith null + } + ReferenceZone(copyZone.packed) + } + return RebuildWorldEntityV4.RebuildWorldEntityZoneProvider { zoneX, zoneZ, level -> + rebuildZones[ZoneKey(zoneX, zoneZ, level)]?.let { ref -> + RebuildRegionZone( + zoneX = ref.zoneX, + zoneZ = ref.zoneZ, + level = ref.level, + rotation = ref.rotation, + ) + } + } + } + override fun release() { val infoPackets = infos.getPackets() val rootPackets = infoPackets.rootWorldInfoPackets @@ -193,6 +333,21 @@ class RspCycle( return } + // Aboard a world entity: the root map recentres on the entity's root-world coords + // via RebuildNormal (mid-sail recentre). Never RebuildRegion here - the deck region + // is loaded through RebuildWorldEntity, and the player's own instance-land coords + // must not drive the root map (regionUid points at the deck region while aboard). + val worldEntity = worldEntities.findAt(coords) + if (worldEntity != null) { + val entityCoords = worldEntity.coords + val rebuild = RebuildNormalV2(entityCoords.x shr 3, entityCoords.z shr 3, worldId) + knownBuildArea = buildArea + knownRegionUid = null + cachedRegionZoneProvider = null + session.queue(rebuild) + return + } + if (regionUid == null) { val rebuild = RebuildNormalV2(x shr 3, z shr 3, worldId) knownBuildArea = buildArea diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspWorldEntityInfo.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspWorldEntityInfo.kt new file mode 100644 index 000000000..f9aeb6804 --- /dev/null +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspWorldEntityInfo.kt @@ -0,0 +1,26 @@ +package org.rsmod.api.net.rsprot + +import net.rsprot.protocol.game.outgoing.info.worldentityinfo.WorldEntityAvatar +import org.rsmod.game.entity.worldentity.WorldEntityInfoProtocol + +class RspWorldEntityInfo(val rspAvatar: WorldEntityAvatar) : WorldEntityInfoProtocol { + override fun updateCoord(level: Int, fineX: Int, fineZ: Int, teleport: Boolean) { + rspAvatar.updateCoord(level, fineX, fineZ, teleport) + } + + override fun updateAngle(angle: Int) { + rspAvatar.updateAngle(angle) + } + + override fun setSequence(seq: Int, delay: Int) { + rspAvatar.extendedInfo.setSequence(seq, delay) + } + + override fun setVisibleOps(ops: Byte) { + rspAvatar.extendedInfo.setVisibleOps(ops) + } + + override fun setSpecific(specific: Boolean) { + rspAvatar.setSpecific(specific) + } +} diff --git a/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistry.kt b/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistry.kt new file mode 100644 index 000000000..ddb3a7747 --- /dev/null +++ b/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistry.kt @@ -0,0 +1,43 @@ +package org.rsmod.api.registry.worldentity + +import jakarta.inject.Inject +import org.rsmod.events.EventBus +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.entity.WorldEntity.Companion.INVALID_SLOT +import org.rsmod.game.entity.WorldEntityList +import org.rsmod.game.entity.worldentity.NoopWorldEntityInfo +import org.rsmod.game.entity.worldentity.WorldEntityStateEvents + +public class WorldEntityRegistry +@Inject +constructor( + private val worldEntityList: WorldEntityList, + private val eventBus: EventBus, +) { + public fun count(): Int = worldEntityList.count() + + public fun add(entity: WorldEntity): WorldEntityRegistryResult.Add { + val slot = + worldEntityList.nextFreeSlot() ?: return WorldEntityRegistryResult.Add.NoAvailableSlot + worldEntityList[slot] = entity + entity.slotId = slot + eventBus.publish(WorldEntityStateEvents.Create(entity)) + return WorldEntityRegistryResult.Add.Success + } + + public fun del(entity: WorldEntity): WorldEntityRegistryResult.Delete { + val slot = entity.slotId + if (slot == INVALID_SLOT) { + return WorldEntityRegistryResult.Delete.UnexpectedSlot + } else if (worldEntityList[slot] != entity) { + return WorldEntityRegistryResult.Delete.ListSlotMismatch(worldEntityList[slot]) + } + worldEntityList.remove(slot) + eventBus.publish(WorldEntityStateEvents.Delete(entity)) + entity.slotId = INVALID_SLOT + entity.infoProtocol = NoopWorldEntityInfo + return WorldEntityRegistryResult.Delete.Success + } + + public fun findAll(): Sequence = worldEntityList.asSequence() +} diff --git a/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistryResult.kt b/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistryResult.kt new file mode 100644 index 000000000..f361f9607 --- /dev/null +++ b/api/registry/src/main/kotlin/org/rsmod/api/registry/worldentity/WorldEntityRegistryResult.kt @@ -0,0 +1,34 @@ +package org.rsmod.api.registry.worldentity + +import kotlin.contracts.contract +import org.rsmod.game.entity.WorldEntity + +public fun WorldEntityRegistryResult.Add.isSuccess(): Boolean { + contract { returns(true) implies (this@isSuccess is WorldEntityRegistryResult.Add.Success) } + return this is WorldEntityRegistryResult.Add.Success +} + +public fun WorldEntityRegistryResult.Delete.isSuccess(): Boolean { + contract { returns(true) implies (this@isSuccess is WorldEntityRegistryResult.Delete.Success) } + return this is WorldEntityRegistryResult.Delete.Success +} + +public class WorldEntityRegistryResult { + public sealed class Add { + public data object Success : Add() + + public sealed class Failure : Add() + + public data object NoAvailableSlot : Failure() + } + + public sealed class Delete { + public data object Success : Delete() + + public sealed class Failure : Delete() + + public data object UnexpectedSlot : Failure() + + public data class ListSlotMismatch(val occupiedBy: WorldEntity?) : Failure() + } +} diff --git a/api/registry/src/main/kotlin/org/rsmod/api/registry/zone/ZoneUpdateTransformer.kt b/api/registry/src/main/kotlin/org/rsmod/api/registry/zone/ZoneUpdateTransformer.kt index 30a2d72cb..8117e4e91 100644 --- a/api/registry/src/main/kotlin/org/rsmod/api/registry/zone/ZoneUpdateTransformer.kt +++ b/api/registry/src/main/kotlin/org/rsmod/api/registry/zone/ZoneUpdateTransformer.kt @@ -22,6 +22,34 @@ import org.rsmod.map.zone.ZoneGrid public object ZoneUpdateTransformer { public fun collectEnclosedProtList(updates: ZoneUpdateList): List = updates.protList + /** + * Collects the observer-specific enclosed prots from [updates] for the given [observerId]: + * private obj updates only when visible to the observer, and obj reveals for everyone except + * the obj's receiver. Used by both the root-world zone update path and the world-entity + * (deck) zone update path. + */ + public fun toObserverEnclosedProtList( + updates: Iterable, + observerId: Long?, + ): List { + val enclosed = ArrayList() + for (update in updates) { + val partial = update as? PartialFollowsZoneProt ?: continue + val prot = + when (partial) { + is ObjPrivateZoneProt -> + if (partial.isVisibleTo(observerId)) partial.backing else null + is ObjReveal -> + if (observerId == partial.obj.receiverId) null else partial.backing + else -> partial.backing + } + if (prot != null) { + enclosed += prot + } + } + return enclosed + } + public fun toPersistentLocChange(loc: LocInfo): ZoneProt { val zoneGrid = ZoneGrid.from(loc.coords) return if (loc.id == LocRegistry.DELETED_LOC_ID) { diff --git a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt new file mode 100644 index 000000000..8efac5186 --- /dev/null +++ b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt @@ -0,0 +1,177 @@ +package org.rsmod.content.other.commands + +import jakarta.inject.Inject +import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccessLauncher +import org.rsmod.api.registry.worldentity.WorldEntityRegistry +import org.rsmod.api.registry.worldentity.isSuccess +import org.rsmod.api.repo.region.RegionRepository +import org.rsmod.api.repo.region.RegionTemplate +import org.rsmod.game.cheat.Cheat +import org.rsmod.game.entity.WorldEntity +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneKey +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class AdminWorldEntityDebugCommands +@Inject +constructor( + private val worldEntityRegistry: WorldEntityRegistry, + private val regionRepo: RegionRepository, + private val protectedAccess: ProtectedAccessLauncher, +) : PluginScript() { + private var lastSpawned: WorldEntity? = null + + override fun ScriptContext.startup() { + onCommand("spawnboat", "Spawn a debug raft world entity at your coords", ::spawnBoat) { + invalidArgs = "Use as ::spawnboat" + } + onCommand("moveboat", "Move the last debug world entity", ::moveBoat) { + invalidArgs = + "Use as ::moveboat dTilesX dTilesZ [angle] [jump] " + + "(ex: ::moveboat 2 0 or ::moveboat 5 5 512 1)" + } + onCommand("delboat", "Delete the last debug world entity", ::delBoat) + onCommand("boardboat", "Teleport aboard the last debug world entity", ::boardBoat) + onCommand("exitboat", "Teleport off the last debug world entity", ::exitBoat) + } + + private fun spawnBoat(cheat: Cheat) = + with(cheat) { + // Copy the raft deck template (all 4 levels) into an instanced region. The hull + // model is baked into the template zone, so the zone copy alone renders the boat. + val template = + RegionTemplate.create { + copyAllLevels(RAFT_TEMPLATE_ZONE_X, RAFT_TEMPLATE_ZONE_Z) { + zoneWidth = RAFT_SIZE_ZONES + zoneLength = RAFT_SIZE_ZONES + } + } + val region = regionRepo.add(template) + if (region == null) { + player.mes("Could not allocate a deck region for the world entity.") + return@with + } + // Protect the region: empty regions (no players inside) are otherwise reclaimed + // by the inactive-region sweep on the next region registration. + regionRepo.protect(region) + val swZone = ZoneKey.from(region.southWest) + val entity = + WorldEntity( + id = RAFT_WE_TYPE_ID, + sizeX = RAFT_SIZE_ZONES, + sizeZ = RAFT_SIZE_ZONES, + southWestZoneX = swZone.x, + southWestZoneZ = swZone.z, + fineX = WorldEntity.tileToFine(player.x + SPAWN_OFFSET_TILES), + fineZ = WorldEntity.tileToFine(player.z), + projectedLevel = player.level, + activeLevel = RAFT_DECK_LEVEL, + ) + entity.region = region + val result = worldEntityRegistry.add(entity) + if (!result.isSuccess()) { + regionRepo.unprotect(region) + player.mes("Could not spawn world entity: $result") + return@with + } + lastSpawned = entity + player.mes("Spawned raft world entity: slot=${entity.slotId}, coords=${entity.coords}") + } + + private fun moveBoat(cheat: Cheat) = + with(cheat) { + val entity = lastSpawned + if (entity == null) { + player.mes("No debug world entity spawned.") + return@with + } + val dx = args[0].toInt() * WorldEntity.FINE_UNITS_PER_TILE + val dz = args[1].toInt() * WorldEntity.FINE_UNITS_PER_TILE + val jump = (args.getOrNull(3)?.toInt() ?: 0) != 0 + entity.updateCoord( + level = entity.projectedLevel, + fineX = entity.fineX + dx, + fineZ = entity.fineZ + dz, + teleport = jump, + ) + args.getOrNull(2)?.toInt()?.let(entity::updateAngle) + player.mes("Moved world entity to ${entity.coords} (angle=${entity.angle}).") + } + + private fun boardBoat(cheat: Cheat) = + with(cheat) { + val entity = lastSpawned + if (entity == null) { + player.mes("No debug world entity spawned.") + return@with + } + val region = entity.region + if (region == null) { + player.mes("World entity has no deck region.") + return@with + } + val deck = + CoordGrid( + region.southWest.x + RAFT_BOARD_DX, + region.southWest.z + RAFT_BOARD_DZ, + RAFT_DECK_LEVEL, + ) + protectedAccess.launch(player) { + player.mes("You board your boat.") + telejump(deck, TeleportType.Exempt) + } + } + + private fun exitBoat(cheat: Cheat) = + with(cheat) { + val dest = player.lastKnownNormalCoord + protectedAccess.launch(player) { + player.mes("You disembark.") + telejump(dest, TeleportType.Exempt) + } + } + + private fun delBoat(cheat: Cheat) = + with(cheat) { + val entity = lastSpawned + if (entity == null) { + player.mes("No debug world entity spawned.") + return@with + } + val result = worldEntityRegistry.del(entity) + // Unprotect the deck region; the registry's inactive-region sweep reclaims it + // (same convention as InstanceManager teardown). + entity.region?.let(regionRepo::unprotect) + entity.region = null + lastSpawned = null + player.mes("Deleted world entity: $result") + } + + private companion object { + private const val SPAWN_OFFSET_TILES = 3 + + /** Cache config `worldentity_1` (rev-240 `dump.worldentity`: minimap_boat_raft). */ + private const val RAFT_WE_TYPE_ID = 1 + + /** Raft deck template zone - tiles (3840, 6456); hull baked into the static map. */ + private const val RAFT_TEMPLATE_ZONE_X = 480 + private const val RAFT_TEMPLATE_ZONE_Z = 807 + private const val RAFT_SIZE_ZONES = 1 + + /** + * Deck entities live on level 1 (`dump.worldentity` `mainlevel=1`; SAW(entity, 1) in + * reference captures). + */ + private const val RAFT_DECK_LEVEL = 1 + + /** + * Raft board tile: `board_dest` template (3843, 6460, 1) minus template base + * (3840, 6456) = offsets (3, 4). The raft boards on its helm tile. + */ + private const val RAFT_BOARD_DX = 3 + private const val RAFT_BOARD_DZ = 4 + } +} diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt index c8f91f3cf..aa5e63764 100644 --- a/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt @@ -182,6 +182,14 @@ public class Player( public var followCoord: CoordGrid = CoordGrid.NULL public var buildArea: CoordGrid = CoordGrid.NULL public val visibleZoneKeys: IntList = IntArrayList() + + /** + * The zone the player's root-world *view* was last processed at. Normally the player's own + * zone; while aboard a world entity it is the entity's root-world zone. Tracked separately + * from `lastProcessedZone`, which other systems rely on for real zone occupancy. + */ + public var lastProcessedViewZone: ZoneKey = ZoneKey.NULL + public var lastMapBuildComplete: Int = Int.MIN_VALUE public val activeAreas: ShortArraySet = ShortArraySet() diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntity.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntity.kt new file mode 100644 index 000000000..c5f68b828 --- /dev/null +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntity.kt @@ -0,0 +1,120 @@ +package org.rsmod.game.entity + +import org.rsmod.game.entity.worldentity.NoopWorldEntityInfo +import org.rsmod.game.entity.worldentity.WorldEntityInfoProtocol +import org.rsmod.game.region.Region +import org.rsmod.map.CoordGrid + +/** + * A world entity is a movable instance rendered in the root world - e.g. a sailing boat. Its + * "body" is a slab of instance land (the deck), anchored at [southWestZoneX]/[southWestZoneZ] + * and spanning [sizeX] x [sizeZ] zones; entities standing on those instance-land coords are + * automatically attributed to this world entity by the client protocol. + * + * The entity renders in the root world at fine-coordinate precision ([fineX]/[fineZ], 128 units + * per tile) with a smooth [angle] (0..2047). All position and angle mutations must go through + * [updateCoord]/[teleport]/[updateAngle] so the client info protocol stays in sync. + */ +public class WorldEntity( + public val id: Int, + public val sizeX: Int, + public val sizeZ: Int, + public val southWestZoneX: Int, + public val southWestZoneZ: Int, + fineX: Int, + fineZ: Int, + public val minLevel: Int = 0, + public val maxLevel: Int = MAX_LEVEL, + public val activeLevel: Int = 0, + projectedLevel: Int = 0, + angle: Int = 0, + public val ownerIndex: Int = NPC_OWNER, +) { + public var slotId: Int = INVALID_SLOT + + public var fineX: Int = fineX + private set + + public var fineZ: Int = fineZ + private set + + public var projectedLevel: Int = projectedLevel + private set + + public var angle: Int = angle and MAX_ANGLE + private set + + /** + * The instanced region backing this world entity's deck, if one has been allocated. Used to + * build the `REBUILD_WORLDENTITY` zone data; when `null`, the entity's sub-scene is empty. + */ + public var region: Region? = null + + public var infoProtocol: WorldEntityInfoProtocol = NoopWorldEntityInfo + + /** The root-world coord grid this entity currently renders at. */ + public val coords: CoordGrid + get() = CoordGrid(fineX shr FINE_BITS, fineZ shr FINE_BITS, projectedLevel) + + /** + * Returns whether the given instance-land [coords] fall within this entity's deck area + * (the zone rectangle anchored at [southWestZoneX]/[southWestZoneZ], spanning [minLevel] + * to [maxLevel]). An entity standing on these coords is aboard this world entity. + */ + public fun contains(coords: CoordGrid): Boolean { + val zoneX = coords.x shr 3 + val zoneZ = coords.z shr 3 + return zoneX >= southWestZoneX && + zoneX < southWestZoneX + sizeX && + zoneZ >= southWestZoneZ && + zoneZ < southWestZoneZ + sizeZ && + coords.level in minLevel..maxLevel + } + + /** + * Updates the root-world render position of this entity. [teleport] jumps the entity to the + * coordinate; otherwise the client interpolates the movement smoothly over the cycle. + */ + public fun updateCoord(level: Int, fineX: Int, fineZ: Int, teleport: Boolean = false) { + this.projectedLevel = level + this.fineX = fineX + this.fineZ = fineZ + infoProtocol.updateCoord(level, fineX, fineZ, teleport) + } + + public fun teleport(level: Int, fineX: Int, fineZ: Int): Unit = + updateCoord(level, fineX, fineZ, teleport = true) + + /** + * Updates the render angle of this entity. Note the client turns at most 128/2048 units per + * game cycle, so large turns animate over multiple cycles. + */ + public fun updateAngle(angle: Int) { + this.angle = angle and MAX_ANGLE + infoProtocol.updateAngle(this.angle) + } + + override fun toString(): String = + "WorldEntity(slot=$slotId, id=$id, coords=$coords, angle=$angle, " + + "swZone=$southWestZoneX/$southWestZoneZ, size=${sizeX}x$sizeZ)" + + public companion object { + public const val INVALID_SLOT: Int = -1 + + /** Owner index for npc-owned entities (rsprot: `< 0` = npc-owned render priority). */ + public const val NPC_OWNER: Int = -1 + + public const val MAX_LEVEL: Int = 3 + + public const val MAX_ANGLE: Int = 2047 + + /** Fine-coordinate units per tile. */ + public const val FINE_UNITS_PER_TILE: Int = 128 + + private const val FINE_BITS: Int = 7 + + /** Converts an absolute tile coordinate to the fine coordinate of the tile's center. */ + public fun tileToFine(tile: Int): Int = + (tile * FINE_UNITS_PER_TILE) + (FINE_UNITS_PER_TILE / 2) + } +} diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntityList.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntityList.kt new file mode 100644 index 000000000..ed4f1d32e --- /dev/null +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/WorldEntityList.kt @@ -0,0 +1,22 @@ +package org.rsmod.game.entity + +import org.rsmod.map.CoordGrid + +/** + * World entity slots are constrained to `1..4095` by the client protocol: index `0` is reserved + * as the root-world sentinel, and the info protocol has a hard capacity of 4096. [slotPadding] + * keeps slot `0` permanently unallocated. + */ +public class WorldEntityList : + EntityList(capacity = CAPACITY, slotPadding = SLOT_PADDING) { + /** + * Returns the world entity whose deck area contains the given instance-land [coords], or + * `null`. An entity standing on those coords is aboard the returned world entity. + */ + public fun findAt(coords: CoordGrid): WorldEntity? = firstOrNull { it.contains(coords) } + + public companion object { + public const val CAPACITY: Int = 4096 + public const val SLOT_PADDING: Int = 1 + } +} diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityInfoProtocol.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityInfoProtocol.kt new file mode 100644 index 000000000..1a0be60af --- /dev/null +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityInfoProtocol.kt @@ -0,0 +1,25 @@ +package org.rsmod.game.entity.worldentity + +public interface WorldEntityInfoProtocol { + public fun updateCoord(level: Int, fineX: Int, fineZ: Int, teleport: Boolean) + + public fun updateAngle(angle: Int) + + public fun setSequence(seq: Int, delay: Int) + + public fun setVisibleOps(ops: Byte) + + public fun setSpecific(specific: Boolean) +} + +public data object NoopWorldEntityInfo : WorldEntityInfoProtocol { + override fun updateCoord(level: Int, fineX: Int, fineZ: Int, teleport: Boolean) {} + + override fun updateAngle(angle: Int) {} + + override fun setSequence(seq: Int, delay: Int) {} + + override fun setVisibleOps(ops: Byte) {} + + override fun setSpecific(specific: Boolean) {} +} diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityStateEvents.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityStateEvents.kt new file mode 100644 index 000000000..743dd3130 --- /dev/null +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/worldentity/WorldEntityStateEvents.kt @@ -0,0 +1,10 @@ +package org.rsmod.game.entity.worldentity + +import org.rsmod.events.UnboundEvent +import org.rsmod.game.entity.WorldEntity + +public class WorldEntityStateEvents { + public data class Create(val entity: WorldEntity) : UnboundEvent + + public data class Delete(val entity: WorldEntity) : UnboundEvent +} diff --git a/server/app/src/main/kotlin/org/rsmod/server/app/modules/GameModule.kt b/server/app/src/main/kotlin/org/rsmod/server/app/modules/GameModule.kt index b65c526b7..637fee86e 100644 --- a/server/app/src/main/kotlin/org/rsmod/server/app/modules/GameModule.kt +++ b/server/app/src/main/kotlin/org/rsmod/server/app/modules/GameModule.kt @@ -12,6 +12,7 @@ import org.rsmod.game.cheat.CheatCommandMap import org.rsmod.game.entity.ControllerList import org.rsmod.game.entity.NpcList import org.rsmod.game.entity.PlayerList +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.queue.EngineQueueCache import org.rsmod.game.region.RegionListLarge import org.rsmod.game.region.RegionListSmall @@ -26,6 +27,7 @@ object GameModule : ExtendedModule() { bindInstance() bindInstance() bindInstance() + bindInstance() bindInstance() bindInstance() bindInstance() From cda27866eae8acd4b0dca85bcde66d1d1bbdff88 Mon Sep 17 00:00:00 2001 From: MouldyToast <53084981+MouldyToast@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:22:11 +0800 Subject: [PATCH 3/4] sailing: Phase 3 S1-S2b - module scaffold, boat lifecycle, dock table, gangplank board/disembark --- .../player/PlayerInteractionProcessor.kt | 29 ++- .../api/net/rsprot/handlers/LocClickLevel.kt | 11 ++ .../api/net/rsprot/handlers/OpLocHandler.kt | 5 +- .../api/net/rsprot/handlers/OpLocTHandler.kt | 5 +- .../commands/AdminWorldEntityDebugCommands.kt | 177 ------------------ content/skills/sailing/build.gradle.kts | 8 + .../org/rsmod/content/skills/sailing/Boat.kt | 19 ++ .../content/skills/sailing/BoatManager.kt | 113 +++++++++++ .../rsmod/content/skills/sailing/BoatType.kt | 66 +++++++ .../org/rsmod/content/skills/sailing/Dock.kt | 82 ++++++++ .../content/skills/sailing/GangplankEvents.kt | 40 ++++ .../skills/sailing/SailingDebugCommands.kt | 141 ++++++++++++++ .../content/skills/sailing/SailingVars.kt | 6 + 13 files changed, 521 insertions(+), 181 deletions(-) create mode 100644 api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/LocClickLevel.kt delete mode 100644 content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt create mode 100644 content/skills/sailing/build.gradle.kts create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Dock.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt diff --git a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerInteractionProcessor.kt b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerInteractionProcessor.kt index d448a6e3e..9618aea50 100644 --- a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerInteractionProcessor.kt +++ b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerInteractionProcessor.kt @@ -1,6 +1,9 @@ package org.rsmod.api.game.process.player import jakarta.inject.Inject +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min import org.rsmod.api.config.Constants import org.rsmod.api.npc.isValidTarget import org.rsmod.api.player.clearInteractionRoute @@ -23,6 +26,8 @@ import org.rsmod.api.route.BoundValidator import org.rsmod.api.route.RayCastValidator import org.rsmod.events.EventBus import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.interact.Interaction import org.rsmod.game.interact.InteractionLoc import org.rsmod.game.interact.InteractionLocOp @@ -34,6 +39,7 @@ import org.rsmod.game.interact.InteractionObj import org.rsmod.game.interact.InteractionPlayer import org.rsmod.game.interact.InteractionPlayerOp import org.rsmod.game.interact.InteractionPlayerT +import org.rsmod.game.loc.BoundLocInfo import org.rsmod.game.movement.RouteRequestPathingEntity import org.rsmod.interact.InteractionStep import org.rsmod.interact.InteractionTarget @@ -41,6 +47,8 @@ import org.rsmod.interact.Interactions import org.rsmod.map.CoordGrid import org.rsmod.routefinder.flag.CollisionFlag +private const val MAX_RIDDEN_OP_RANGE: Int = 8 + public class PlayerInteractionProcessor @Inject constructor( @@ -58,6 +66,7 @@ constructor( private val playerTInteractions: PlayerTInteractions, private val protectedAccess: ProtectedAccessLauncher, private val movement: PlayerMovementProcessor, + private val worldEntities: WorldEntityList, ) { public fun process(player: Player) { // Store the current interaction at this stage to ensure that if an interaction triggers a @@ -257,9 +266,25 @@ constructor( validApLine = isWithinApRange(interaction), ) - private fun Player.isWithinOpRange(interaction: InteractionLoc): Boolean = - boundValidator.collides(source = avatar, target = interaction.target) || + private fun Player.isWithinOpRange(interaction: InteractionLoc): Boolean { + val ridden = worldEntities.findAt(coords) + if (ridden != null && !ridden.contains(interaction.target.coords)) { + return isWithinRiddenOpRange(ridden, interaction.target) + } + return boundValidator.collides(source = avatar, target = interaction.target) || boundValidator.touches(source = avatar, target = interaction.target) + } + + private fun isWithinRiddenOpRange(ridden: WorldEntity, target: BoundLocInfo): Boolean { + val root = ridden.coords + if (root.level != target.level) { + return false + } + val nearestX = max(target.x, min(root.x, target.x + target.adjustedWidth - 1)) + val nearestZ = max(target.z, min(root.z, target.z + target.adjustedLength - 1)) + val distance = max(abs(root.x - nearestX), abs(root.z - nearestZ)) + return distance <= MAX_RIDDEN_OP_RANGE + } private fun Player.isWithinApRange(interaction: InteractionLoc): Boolean = isValidApRange( diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/LocClickLevel.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/LocClickLevel.kt new file mode 100644 index 000000000..543ad530a --- /dev/null +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/LocClickLevel.kt @@ -0,0 +1,11 @@ +package org.rsmod.api.net.rsprot.handlers + +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntityList +import org.rsmod.map.CoordGrid + +internal fun WorldEntityList.locClickLevel(player: Player, x: Int, z: Int): Int { + val ridden = findAt(player.coords) ?: return player.level + val clicked = CoordGrid(x, z, player.level) + return if (ridden.contains(clicked)) player.level else ridden.projectedLevel +} diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocHandler.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocHandler.kt index d556748bb..a65b9b4a4 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocHandler.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocHandler.kt @@ -11,6 +11,7 @@ import org.rsmod.api.player.vars.ctrlMoveSpeed import org.rsmod.api.registry.loc.LocRegistry import org.rsmod.events.EventBus import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.interact.InteractionLocOp import org.rsmod.game.interact.InteractionOp import org.rsmod.game.loc.BoundLocInfo @@ -23,6 +24,7 @@ constructor( private val eventBus: EventBus, private val locRegistry: LocRegistry, private val locInteractions: LocInteractions, + private val worldEntities: WorldEntityList, ) : MessageHandler { private val logger = InlineLogger() @@ -41,7 +43,8 @@ constructor( if (player.isDelayed) { return } - val coords = CoordGrid(message.x, message.z, player.level) + val coords = + CoordGrid(message.x, message.z, worldEntities.locClickLevel(player, message.x, message.z)) val loc = locRegistry.findType(coords, message.id) if (loc == null) { player.clearMapFlag() diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocTHandler.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocTHandler.kt index 16219b8e5..e5ef66542 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocTHandler.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/OpLocTHandler.kt @@ -13,6 +13,7 @@ import org.rsmod.api.player.vars.ctrlMoveSpeed import org.rsmod.api.registry.loc.LocRegistry import org.rsmod.events.EventBus import org.rsmod.game.entity.Player +import org.rsmod.game.entity.WorldEntityList import org.rsmod.game.interact.InteractionLocT import org.rsmod.game.loc.BoundLocInfo import org.rsmod.game.movement.RouteRequestLoc @@ -25,6 +26,7 @@ constructor( private val eventBus: EventBus, private val locRegistry: LocRegistry, private val locInteractions: LocTInteractions, + private val worldEntities: WorldEntityList, ) : MessageHandler { private val logger = InlineLogger() @@ -36,7 +38,8 @@ constructor( return } - val coords = CoordGrid(message.x, message.z, player.level) + val coords = + CoordGrid(message.x, message.z, worldEntities.locClickLevel(player, message.x, message.z)) val loc = locRegistry.findType(coords, message.id) if (loc == null) { player.clearMapFlag() diff --git a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt deleted file mode 100644 index 8efac5186..000000000 --- a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminWorldEntityDebugCommands.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.rsmod.content.other.commands - -import jakarta.inject.Inject -import org.rsmod.api.player.hook.TeleportType -import org.rsmod.api.player.output.mes -import org.rsmod.api.player.protect.ProtectedAccessLauncher -import org.rsmod.api.registry.worldentity.WorldEntityRegistry -import org.rsmod.api.registry.worldentity.isSuccess -import org.rsmod.api.repo.region.RegionRepository -import org.rsmod.api.repo.region.RegionTemplate -import org.rsmod.game.cheat.Cheat -import org.rsmod.game.entity.WorldEntity -import org.rsmod.map.CoordGrid -import org.rsmod.map.zone.ZoneKey -import org.rsmod.plugin.scripts.PluginScript -import org.rsmod.plugin.scripts.ScriptContext - -class AdminWorldEntityDebugCommands -@Inject -constructor( - private val worldEntityRegistry: WorldEntityRegistry, - private val regionRepo: RegionRepository, - private val protectedAccess: ProtectedAccessLauncher, -) : PluginScript() { - private var lastSpawned: WorldEntity? = null - - override fun ScriptContext.startup() { - onCommand("spawnboat", "Spawn a debug raft world entity at your coords", ::spawnBoat) { - invalidArgs = "Use as ::spawnboat" - } - onCommand("moveboat", "Move the last debug world entity", ::moveBoat) { - invalidArgs = - "Use as ::moveboat dTilesX dTilesZ [angle] [jump] " + - "(ex: ::moveboat 2 0 or ::moveboat 5 5 512 1)" - } - onCommand("delboat", "Delete the last debug world entity", ::delBoat) - onCommand("boardboat", "Teleport aboard the last debug world entity", ::boardBoat) - onCommand("exitboat", "Teleport off the last debug world entity", ::exitBoat) - } - - private fun spawnBoat(cheat: Cheat) = - with(cheat) { - // Copy the raft deck template (all 4 levels) into an instanced region. The hull - // model is baked into the template zone, so the zone copy alone renders the boat. - val template = - RegionTemplate.create { - copyAllLevels(RAFT_TEMPLATE_ZONE_X, RAFT_TEMPLATE_ZONE_Z) { - zoneWidth = RAFT_SIZE_ZONES - zoneLength = RAFT_SIZE_ZONES - } - } - val region = regionRepo.add(template) - if (region == null) { - player.mes("Could not allocate a deck region for the world entity.") - return@with - } - // Protect the region: empty regions (no players inside) are otherwise reclaimed - // by the inactive-region sweep on the next region registration. - regionRepo.protect(region) - val swZone = ZoneKey.from(region.southWest) - val entity = - WorldEntity( - id = RAFT_WE_TYPE_ID, - sizeX = RAFT_SIZE_ZONES, - sizeZ = RAFT_SIZE_ZONES, - southWestZoneX = swZone.x, - southWestZoneZ = swZone.z, - fineX = WorldEntity.tileToFine(player.x + SPAWN_OFFSET_TILES), - fineZ = WorldEntity.tileToFine(player.z), - projectedLevel = player.level, - activeLevel = RAFT_DECK_LEVEL, - ) - entity.region = region - val result = worldEntityRegistry.add(entity) - if (!result.isSuccess()) { - regionRepo.unprotect(region) - player.mes("Could not spawn world entity: $result") - return@with - } - lastSpawned = entity - player.mes("Spawned raft world entity: slot=${entity.slotId}, coords=${entity.coords}") - } - - private fun moveBoat(cheat: Cheat) = - with(cheat) { - val entity = lastSpawned - if (entity == null) { - player.mes("No debug world entity spawned.") - return@with - } - val dx = args[0].toInt() * WorldEntity.FINE_UNITS_PER_TILE - val dz = args[1].toInt() * WorldEntity.FINE_UNITS_PER_TILE - val jump = (args.getOrNull(3)?.toInt() ?: 0) != 0 - entity.updateCoord( - level = entity.projectedLevel, - fineX = entity.fineX + dx, - fineZ = entity.fineZ + dz, - teleport = jump, - ) - args.getOrNull(2)?.toInt()?.let(entity::updateAngle) - player.mes("Moved world entity to ${entity.coords} (angle=${entity.angle}).") - } - - private fun boardBoat(cheat: Cheat) = - with(cheat) { - val entity = lastSpawned - if (entity == null) { - player.mes("No debug world entity spawned.") - return@with - } - val region = entity.region - if (region == null) { - player.mes("World entity has no deck region.") - return@with - } - val deck = - CoordGrid( - region.southWest.x + RAFT_BOARD_DX, - region.southWest.z + RAFT_BOARD_DZ, - RAFT_DECK_LEVEL, - ) - protectedAccess.launch(player) { - player.mes("You board your boat.") - telejump(deck, TeleportType.Exempt) - } - } - - private fun exitBoat(cheat: Cheat) = - with(cheat) { - val dest = player.lastKnownNormalCoord - protectedAccess.launch(player) { - player.mes("You disembark.") - telejump(dest, TeleportType.Exempt) - } - } - - private fun delBoat(cheat: Cheat) = - with(cheat) { - val entity = lastSpawned - if (entity == null) { - player.mes("No debug world entity spawned.") - return@with - } - val result = worldEntityRegistry.del(entity) - // Unprotect the deck region; the registry's inactive-region sweep reclaims it - // (same convention as InstanceManager teardown). - entity.region?.let(regionRepo::unprotect) - entity.region = null - lastSpawned = null - player.mes("Deleted world entity: $result") - } - - private companion object { - private const val SPAWN_OFFSET_TILES = 3 - - /** Cache config `worldentity_1` (rev-240 `dump.worldentity`: minimap_boat_raft). */ - private const val RAFT_WE_TYPE_ID = 1 - - /** Raft deck template zone - tiles (3840, 6456); hull baked into the static map. */ - private const val RAFT_TEMPLATE_ZONE_X = 480 - private const val RAFT_TEMPLATE_ZONE_Z = 807 - private const val RAFT_SIZE_ZONES = 1 - - /** - * Deck entities live on level 1 (`dump.worldentity` `mainlevel=1`; SAW(entity, 1) in - * reference captures). - */ - private const val RAFT_DECK_LEVEL = 1 - - /** - * Raft board tile: `board_dest` template (3843, 6460, 1) minus template base - * (3840, 6456) = offsets (3, 4). The raft boards on its helm tile. - */ - private const val RAFT_BOARD_DX = 3 - private const val RAFT_BOARD_DZ = 4 - } -} diff --git a/content/skills/sailing/build.gradle.kts b/content/skills/sailing/build.gradle.kts new file mode 100644 index 000000000..3bf859ff1 --- /dev/null +++ b/content/skills/sailing/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("base-conventions") +} + +dependencies { + implementation(projects.api.pluginCommons) + implementation(projects.api.registry) +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt new file mode 100644 index 000000000..9d8118a3d --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt @@ -0,0 +1,19 @@ +package org.rsmod.content.skills.sailing + +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.region.Region +import org.rsmod.map.CoordGrid + +class Boat(val type: BoatType, val entity: WorldEntity, val region: Region) { + var dock: Dock? = null + + val boardDest: CoordGrid + get() = + CoordGrid( + region.southWest.x + type.boardDestDx, + region.southWest.z + type.boardDestDz, + type.deckLevel, + ) + + override fun toString(): String = "Boat(type=${type.key}, entity=$entity)" +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt new file mode 100644 index 000000000..69b98c49f --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt @@ -0,0 +1,113 @@ +package org.rsmod.content.skills.sailing + +import com.github.michaelbull.logging.InlineLogger +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.rsmod.api.player.output.mes +import org.rsmod.api.registry.worldentity.WorldEntityRegistry +import org.rsmod.api.registry.worldentity.WorldEntityRegistryResult +import org.rsmod.api.registry.worldentity.isSuccess +import org.rsmod.api.repo.region.RegionRepository +import org.rsmod.api.repo.region.RegionTemplate +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.PlayerList +import org.rsmod.game.entity.WorldEntity +import org.rsmod.game.entity.util.PathingEntityCommon +import org.rsmod.map.CoordGrid +import org.rsmod.routefinder.collision.CollisionFlagMap + +@Singleton +class BoatManager +@Inject +constructor( + private val regionRepo: RegionRepository, + private val worldEntityRegistry: WorldEntityRegistry, + private val playerList: PlayerList, + private val collision: CollisionFlagMap, +) { + private val logger = InlineLogger() + + private val boats = HashMap() + + fun spawn(type: BoatType, level: Int, fineX: Int, fineZ: Int, angle: Int = 0): Boat? { + val template = + RegionTemplate.create { + copyAllLevels(type.templateZoneX, type.templateZoneZ) { + zoneWidth = type.sizeZonesX + zoneLength = type.sizeZonesZ + } + } + val region = regionRepo.add(template) + if (region == null) { + logger.error { "Could not allocate a deck region for boat type `${type.key}`." } + return null + } + regionRepo.protect(region) + val swZone = region.southWestZone + val entity = + WorldEntity( + id = type.worldEntityType, + sizeX = type.sizeZonesX, + sizeZ = type.sizeZonesZ, + southWestZoneX = swZone.x, + southWestZoneZ = swZone.z, + fineX = fineX, + fineZ = fineZ, + activeLevel = type.deckLevel, + projectedLevel = level, + angle = angle, + ) + entity.region = region + val result = worldEntityRegistry.add(entity) + if (!result.isSuccess()) { + regionRepo.unprotect(region) + logger.error { "Could not register boat world entity `${type.key}`: $result" } + return null + } + val boat = Boat(type, entity, region) + boats[entity.slotId] = boat + return boat + } + + fun spawnAtDock(type: BoatType, dock: Dock): Boat? { + val boat = + spawn( + type = type, + level = dock.boatTile.level, + fineX = WorldEntity.tileToFine(dock.boatTile.x) + type.dockFineDx, + fineZ = WorldEntity.tileToFine(dock.boatTile.z) + type.dockFineDz, + angle = dock.angle, + ) + boat?.dock = dock + return boat + } + + fun mooredAt(dock: Dock): Boat? = boats.values.firstOrNull { it.dock == dock } + + fun despawn(boat: Boat): WorldEntityRegistryResult.Delete { + evacuate(boat) + val slot = boat.entity.slotId + val result = worldEntityRegistry.del(boat.entity) + boat.entity.region = null + regionRepo.unprotect(boat.region) + if (slot != WorldEntity.INVALID_SLOT) { + boats.remove(slot) + } + return result + } + + fun boatAt(coords: CoordGrid): Boat? = boats.values.firstOrNull { it.entity.contains(coords) } + + fun boatOf(player: Player): Boat? = boatAt(player.coords) + + private fun evacuate(boat: Boat) { + for (player in playerList) { + if (!boat.entity.contains(player.coords)) { + continue + } + PathingEntityCommon.telejump(player, collision, player.lastKnownNormalCoord) + player.aboardPlayerBoat = 0 + player.mes("You are returned to shore.") + } + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt new file mode 100644 index 000000000..83bed67dc --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt @@ -0,0 +1,66 @@ +package org.rsmod.content.skills.sailing + +data class BoatType( + val key: String, + val worldEntityType: Int, + val templateZoneX: Int, + val templateZoneZ: Int, + val sizeZonesX: Int, + val sizeZonesZ: Int, + val deckLevel: Int, + val boardDestDx: Int, + val boardDestDz: Int, + val dockFineDx: Int, + val dockFineDz: Int, +) + +object BoatTypes { + val RAFT = + BoatType( + key = "raft", + worldEntityType = 1, + templateZoneX = 480, + templateZoneZ = 807, + sizeZonesX = 1, + sizeZonesZ = 1, + deckLevel = 1, + boardDestDx = 3, + boardDestDz = 4, + dockFineDx = 0, + dockFineDz = 0, + ) + + val SKIFF = + BoatType( + key = "skiff", + worldEntityType = 2, + templateZoneX = 480, + templateZoneZ = 806, + sizeZonesX = 1, + sizeZonesZ = 1, + deckLevel = 1, + boardDestDx = 4, + boardDestDz = 4, + dockFineDx = 128, + dockFineDz = 0, + ) + + val SLOOP = + BoatType( + key = "sloop", + worldEntityType = 3, + templateZoneX = 480, + templateZoneZ = 804, + sizeZonesX = 1, + sizeZonesZ = 2, + deckLevel = 1, + boardDestDx = 4, + boardDestDz = 10, + dockFineDx = 192, + dockFineDz = 0, + ) + + val all = listOf(RAFT, SKIFF, SLOOP) + + fun byKey(key: String): BoatType? = all.firstOrNull { it.key.equals(key, ignoreCase = true) } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Dock.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Dock.kt new file mode 100644 index 000000000..eeff3f4c7 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Dock.kt @@ -0,0 +1,82 @@ +package org.rsmod.content.skills.sailing + +import org.rsmod.map.CoordGrid + +data class Dock( + val name: String, + val returnTile: CoordGrid, + val gangplankTile: CoordGrid, + val boatTile: CoordGrid, + val sailingLevel: Int, + val angle: Int = 0, +) + +object Docks { + val all = + listOf( + dock("Port Sarim", 3050, 3193, 3051, 3194, 3053, 3193, 1), + dock("The Pandemonium", 3069, 2987, 3070, 2987, 3072, 2987, 1), + dock("Land's End", 1506, 3403, 1507, 3403, 1509, 3403, 5), + dock("Hosidius", 1726, 3453, 1726, 3452, 1726, 3450, 5, angle = 512), + dock("Musa Point", 2960, 3146, 2961, 3146, 2963, 3146, 10, angle = 1024), + dock("Port Piscarilius", 1845, 3688, 1845, 3687, 1845, 3684, 15), + dock("Rimmington", 2924, 3175, 2925, 3175, 2927, 3175, 18), + dock("Catherby", 2793, 3408, 2794, 3408, 2796, 3408, 20), + dock("Brimhaven", 2751, 3231, 2752, 3231, 2754, 3231, 25), + dock("Ardougne", 2667, 3259, 2668, 3259, 2670, 3259, 28), + dock("Port Khazard", 2685, 3162, 2686, 3162, 2688, 3162, 30), + dock("Witchaven", 2726, 3286, 2727, 3286, 2729, 3286, 34), + dock("Entrana", 2880, 3336, 2881, 3336, 2883, 3336, 36), + dock("Civitas illa Fortis", 1766, 3144, 1767, 3144, 1769, 3144, 38), + dock("Corsair Cove", 2583, 2844, 2584, 2844, 2586, 2844, 40), + dock("Cairn Isle", 2742, 2952, 2743, 2952, 2745, 2952, 42), + dock("Sunset Coast", 1513, 2974, 1512, 2974, 1510, 2974, 44), + dock("The Summer Shore", 3174, 2368, 3172, 2367, 3174, 2364, 45), + dock("Aldarin", 1452, 2969, 1452, 2970, 1452, 2973, 46, angle = 1024), + dock("Ruins of Unkah", 3145, 2825, 3141, 2824, 3143, 2824, 48), + dock("Void Knights' Outpost", 2648, 2683, 2649, 2683, 2651, 2683, 50), + dock("Port Roberts", 1855, 3307, 1856, 3307, 1858, 3307, 50), + dock("Red Rock", 2811, 2510, 2812, 2510, 2814, 2510, 50), + dock("Rellekka", 2627, 3709, 2628, 3709, 2630, 3709, 62), + dock("Etceteria", 2609, 3836, 2610, 3836, 2612, 3836, 65), + dock("Port Tyras", 2138, 3115, 2139, 3115, 2141, 3115, 66), + dock("Deepfin Point", 1920, 2752, 1921, 2752, 1923, 2752, 67), + dock("Jatizso", 2409, 3776, 2410, 3776, 2412, 3776, 68), + dock("Neitiznot", 2299, 3782, 2300, 3782, 2302, 3782, 68), + dock("Prifddinas", 2155, 3319, 2156, 3319, 2158, 3319, 70), + dock("Piscatoris", 2297, 3689, 2298, 3689, 2300, 3689, 75), + dock("Lunar Isle", 2154, 3881, 2155, 3881, 2157, 3881, 76), + ) + + fun byName(name: String): Dock? = all.firstOrNull { it.name.equals(name, ignoreCase = true) } + + fun nearest(coords: CoordGrid, maxDistance: Int = MAX_GANGPLANK_DISTANCE): Dock? { + val dock = all.minByOrNull { it.gangplankTile.chebyshevDistance(coords) } ?: return null + val within = + dock.gangplankTile.level == coords.level && + dock.gangplankTile.chebyshevDistance(coords) <= maxDistance + return if (within) dock else null + } + + private const val MAX_GANGPLANK_DISTANCE = 8 + + private fun dock( + name: String, + retX: Int, + retZ: Int, + plankX: Int, + plankZ: Int, + boatX: Int, + boatZ: Int, + sailingLevel: Int, + angle: Int = 0, + ): Dock = + Dock( + name = name, + returnTile = CoordGrid(retX, retZ), + gangplankTile = CoordGrid(plankX, plankZ), + boatTile = CoordGrid(boatX, boatZ), + sailingLevel = sailingLevel, + angle = angle, + ) +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt new file mode 100644 index 000000000..41b76a916 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt @@ -0,0 +1,40 @@ +package org.rsmod.content.skills.sailing + +import jakarta.inject.Inject +import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class GangplankEvents +@Inject +constructor(private val boats: BoatManager) : PluginScript() { + override fun ScriptContext.startup() { + onOpLoc1("loc.sailing_gangplank_embark") { + val dock = Docks.nearest(it.loc.coords) + if (dock == null) { + mes("Nothing interesting happens.") + return@onOpLoc1 + } + val boat = boats.mooredAt(dock) ?: boats.spawnAtDock(BoatTypes.RAFT, dock) + if (boat == null) { + mes("Your boat cannot moor here right now.") + return@onOpLoc1 + } + player.aboardPlayerBoat = 1 + telejump(boat.boardDest, TeleportType.Exempt) + mes("You board your boat.") + } + onOpLoc1("loc.sailing_gangplank_disembark") { + val dock = Docks.nearest(it.loc.coords) + val dest = dock?.returnTile ?: player.lastKnownNormalCoord + player.aboardPlayerBoat = 0 + telejump(dest, TeleportType.Exempt) + if (dock != null) { + mes("You disembark at ${dock.name}.") + } else { + mes("You disembark.") + } + } + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt new file mode 100644 index 000000000..e00052bc1 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt @@ -0,0 +1,141 @@ +package org.rsmod.content.skills.sailing + +import dev.or2.central.account.Rights +import jakarta.inject.Inject +import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccessLauncher +import org.rsmod.api.script.onCommand +import org.rsmod.game.cheat.Cheat +import org.rsmod.game.entity.WorldEntity +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class SailingDebugCommands +@Inject +constructor( + private val boats: BoatManager, + private val protectedAccess: ProtectedAccessLauncher, +) : PluginScript() { + private var lastSpawned: Boat? = null + + override fun ScriptContext.startup() { + onCommand("spawnboat") { + desc = "Spawn a debug boat world entity at your coords" + requiredRights = Rights.ADMINISTRATOR + invalidArgs = "Use as ::spawnboat [raft|skiff|sloop]" + cheat(::spawnBoat) + } + onCommand("moveboat") { + desc = "Move the last debug boat" + requiredRights = Rights.ADMINISTRATOR + invalidArgs = + "Use as ::moveboat dTilesX dTilesZ [angle] [jump] " + + "(ex: ::moveboat 2 0 or ::moveboat 5 5 512 1)" + cheat(::moveBoat) + } + onCommand("delboat") { + desc = "Delete the last debug boat" + requiredRights = Rights.ADMINISTRATOR + cheat(::delBoat) + } + onCommand("boardboat") { + desc = "Teleport aboard the last debug boat" + requiredRights = Rights.ADMINISTRATOR + cheat(::boardBoat) + } + onCommand("exitboat") { + desc = "Teleport off the last debug boat" + requiredRights = Rights.ADMINISTRATOR + cheat(::exitBoat) + } + } + + private fun spawnBoat(cheat: Cheat) = + with(cheat) { + val key = args.getOrNull(0) ?: BoatTypes.RAFT.key + val type = BoatTypes.byKey(key) + if (type == null) { + player.mes("Unknown boat type: $key (use raft, skiff, or sloop)") + return@with + } + val boat = + boats.spawn( + type = type, + level = player.level, + fineX = WorldEntity.tileToFine(player.x + SPAWN_OFFSET_TILES), + fineZ = WorldEntity.tileToFine(player.z), + ) + if (boat == null) { + player.mes("Could not spawn ${type.key} world entity.") + return@with + } + lastSpawned = boat + player.mes( + "Spawned ${type.key} world entity: " + + "slot=${boat.entity.slotId}, coords=${boat.entity.coords}" + ) + } + + private fun moveBoat(cheat: Cheat) = + with(cheat) { + val boat = lastSpawned + if (boat == null) { + player.mes("No debug boat spawned.") + return@with + } + val entity = boat.entity + val dx = args[0].toInt() * WorldEntity.FINE_UNITS_PER_TILE + val dz = args[1].toInt() * WorldEntity.FINE_UNITS_PER_TILE + val jump = (args.getOrNull(3)?.toInt() ?: 0) != 0 + entity.updateCoord( + level = entity.projectedLevel, + fineX = entity.fineX + dx, + fineZ = entity.fineZ + dz, + teleport = jump, + ) + args.getOrNull(2)?.toInt()?.let(entity::updateAngle) + player.mes("Moved boat to ${entity.coords} (angle=${entity.angle}).") + } + + private fun boardBoat(cheat: Cheat) = + with(cheat) { + val boat = lastSpawned + if (boat == null) { + player.mes("No debug boat spawned.") + return@with + } + val dest = boat.boardDest + protectedAccess.launch(player) { + player.aboardPlayerBoat = 1 + player.mes("You board your boat.") + telejump(dest, TeleportType.Exempt) + } + } + + private fun exitBoat(cheat: Cheat) = + with(cheat) { + val dest = player.lastKnownNormalCoord + protectedAccess.launch(player) { + player.aboardPlayerBoat = 0 + player.mes("You disembark.") + telejump(dest, TeleportType.Exempt) + } + } + + private fun delBoat(cheat: Cheat) = + with(cheat) { + val boat = lastSpawned + if (boat == null) { + player.mes("No debug boat spawned.") + return@with + } + val result = boats.despawn(boat) + lastSpawned = null + player.mes("Deleted boat: $result") + } + + private companion object { + private const val SPAWN_OFFSET_TILES = 3 + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt new file mode 100644 index 000000000..fb8901ec8 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt @@ -0,0 +1,6 @@ +package org.rsmod.content.skills.sailing + +import org.rsmod.api.player.vars.intVarBit +import org.rsmod.game.entity.Player + +internal var Player.aboardPlayerBoat by intVarBit("varbit.sailing_player_is_on_player_boat") From 4c61b71729718e920aa97635eb2bd3012c109ece Mon Sep 17 00:00:00 2001 From: MouldyToast <53084981+MouldyToast@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:47:02 +0800 Subject: [PATCH 4/4] Add sailing kinematics, helm navigation, and deck furnishing - BoatKinematics: per-tick speed ramp, shortest-path turning, fine-coord integration on LateCycle; ::setheading/::setspeed debug commands - Helm: Navigate/Stop-navigating across all 42 steering multiloc variants, SET_HEADING net handler + SailingEvent bridge, heading/walk interaction tile modes, releaseHelm teardown on every off-boat path - Deck furnishing: per-type facility locs (helm, sails, cargo, keel, trim, ambience) spawned onto the boat region at spawn --- .../net/rsprot/handlers/SetHeadingHandler.kt | 17 +++ .../provider/MessageConsumerProvider.kt | 4 + .../api/player/output/InteractionModes.kt | 27 ++++ .../rsmod/api/player/events/SailingEvent.kt | 16 +++ .../org/rsmod/content/skills/sailing/Boat.kt | 9 +- .../content/skills/sailing/BoatKinematics.kt | 64 +++++++++ .../skills/sailing/BoatKinematicsScript.kt | 15 ++ .../content/skills/sailing/BoatManager.kt | 41 ++++++ .../rsmod/content/skills/sailing/BoatType.kt | 28 ++++ .../rsmod/content/skills/sailing/DeckLoc.kt | 11 ++ .../rsmod/content/skills/sailing/DeckLocs.kt | 60 ++++++++ .../content/skills/sailing/GangplankEvents.kt | 1 + .../content/skills/sailing/HelmEvents.kt | 131 ++++++++++++++++++ .../skills/sailing/SailingDebugCommands.kt | 39 ++++++ .../skills/sailing/SailingMoveModes.kt | 9 ++ .../content/skills/sailing/SailingVars.kt | 2 + 16 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/SetHeadingHandler.kt create mode 100644 api/player-output/src/main/kotlin/org/rsmod/api/player/output/InteractionModes.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/events/SailingEvent.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematics.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematicsScript.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLoc.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLocs.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/HelmEvents.kt create mode 100644 content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingMoveModes.kt diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/SetHeadingHandler.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/SetHeadingHandler.kt new file mode 100644 index 000000000..4fc806e70 --- /dev/null +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/SetHeadingHandler.kt @@ -0,0 +1,17 @@ +package org.rsmod.api.net.rsprot.handlers + +import jakarta.inject.Inject +import net.rsprot.protocol.game.incoming.misc.user.SetHeading +import org.rsmod.api.player.events.SailingEvent +import org.rsmod.events.EventBus +import org.rsmod.game.entity.Player + +class SetHeadingHandler @Inject constructor(private val eventBus: EventBus) : + MessageHandler { + override fun handle(player: Player, message: SetHeading) { + if (message.heading > 15) { + return + } + eventBus.publish(SailingEvent.SetHeading(player, message.heading)) + } +} diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt index 6b970ebe6..f3505615c 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt @@ -21,6 +21,7 @@ import net.rsprot.protocol.game.incoming.misc.user.CloseModal import net.rsprot.protocol.game.incoming.misc.user.MoveGameClick import net.rsprot.protocol.game.incoming.misc.user.MoveMinimapClick import net.rsprot.protocol.game.incoming.misc.user.SetChatFilterSettings +import net.rsprot.protocol.game.incoming.misc.user.SetHeading import net.rsprot.protocol.game.incoming.npcs.OpNpcV2 import net.rsprot.protocol.game.incoming.npcs.OpNpc6 import net.rsprot.protocol.game.incoming.npcs.OpNpcT @@ -72,6 +73,7 @@ import org.rsmod.api.net.rsprot.handlers.ResumePObjDialogHandler import org.rsmod.api.net.rsprot.handlers.ResumePStringDialogHandler import org.rsmod.api.net.rsprot.handlers.ResumePauseButtonHandler import org.rsmod.api.net.rsprot.handlers.SetChatFilterSettingsHandler +import org.rsmod.api.net.rsprot.handlers.SetHeadingHandler import org.rsmod.api.net.rsprot.handlers.WindowStatusHandler import org.rsmod.game.entity.Player @@ -99,6 +101,7 @@ constructor( private val ignoreListAdd: IgnoreListAddHandler, private val ignoreListDelete: IgnoreListDeleteHandler, private val setChatFilterSettings: SetChatFilterSettingsHandler, + private val setHeading: SetHeadingHandler, private val if3Button: If3ButtonHandler, private val closeModal: CloseModalHandler, private val resumePauseButton: ResumePauseButtonHandler, @@ -136,6 +139,7 @@ constructor( builder.addListener(IgnoreListAdd::class.java, ignoreListAdd) builder.addListener(IgnoreListDel::class.java, ignoreListDelete) builder.addListener(SetChatFilterSettings::class.java, setChatFilterSettings) + builder.addListener(SetHeading::class.java, setHeading) builder.addListener(If3Button::class.java, if3Button) builder.addListener(CloseModal::class.java, closeModal) builder.addListener(ResumePauseButton::class.java, resumePauseButton) diff --git a/api/player-output/src/main/kotlin/org/rsmod/api/player/output/InteractionModes.kt b/api/player-output/src/main/kotlin/org/rsmod/api/player/output/InteractionModes.kt new file mode 100644 index 000000000..731c12411 --- /dev/null +++ b/api/player-output/src/main/kotlin/org/rsmod/api/player/output/InteractionModes.kt @@ -0,0 +1,27 @@ +package org.rsmod.api.player.output + +import net.rsprot.protocol.game.outgoing.misc.client.ResetInteractionMode +import net.rsprot.protocol.game.outgoing.misc.client.SetInteractionMode +import org.rsmod.game.entity.Player + +public object InteractionModes { + public const val WORLD_DEFAULT: Int = -2 + + public const val TILE_MODE_DISABLED: Int = 0 + public const val TILE_MODE_WALK: Int = 1 + public const val TILE_MODE_HEADING: Int = 2 + + public const val ENTITY_MODE_DISABLED: Int = 0 + public const val ENTITY_MODE_ALL: Int = 1 + public const val ENTITY_MODE_EXAMINE: Int = 2 + + /** @see [SetInteractionMode] */ + public fun setInteractionMode(player: Player, worldId: Int, tileMode: Int, entityMode: Int) { + player.client.write(SetInteractionMode(worldId, tileMode, entityMode)) + } + + /** @see [ResetInteractionMode] */ + public fun resetInteractionMode(player: Player, worldId: Int) { + player.client.write(ResetInteractionMode(worldId)) + } +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/events/SailingEvent.kt b/api/player/src/main/kotlin/org/rsmod/api/player/events/SailingEvent.kt new file mode 100644 index 000000000..eda7bf4b8 --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/events/SailingEvent.kt @@ -0,0 +1,16 @@ +package org.rsmod.api.player.events + +import org.rsmod.events.UnboundEvent +import org.rsmod.game.entity.Player + +public class SailingEvent { + /** + * Published by the net layer for the client's `SET_HEADING` packet, sent while a world's + * tile interaction mode is `heading`. [heading] is 0-15 inclusive — the 0-2047 world-entity + * angle divided by 128. + */ + public data class SetHeading( + val player: Player, + val heading: Int + ) : UnboundEvent +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt index 9d8118a3d..b063e0d3f 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/Boat.kt @@ -1,11 +1,18 @@ package org.rsmod.content.skills.sailing +import org.rsmod.game.entity.Player import org.rsmod.game.entity.WorldEntity import org.rsmod.game.region.Region import org.rsmod.map.CoordGrid class Boat(val type: BoatType, val entity: WorldEntity, val region: Region) { - var dock: Dock? = null + var dock: Dock? = null + + var targetAngle: Int = entity.angle + var speed: Int = 0 + var targetSpeed: Int = 0 + var helmsman: Player? = null + var moveMode: Int = SailingMoveModes.STOPPED val boardDest: CoordGrid get() = diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematics.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematics.kt new file mode 100644 index 000000000..db6f2c374 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematics.kt @@ -0,0 +1,64 @@ +package org.rsmod.content.skills.sailing + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +@Singleton +class BoatKinematics @Inject constructor(private val boats: BoatManager) { + fun tick() { + for (boat in boats.all) { + step(boat) + } + } + + private fun step(boat: Boat) { + val accel = boat.type.acceleration + if (boat.speed < boat.targetSpeed) { + boat.speed = min(boat.speed + accel, boat.targetSpeed) + } else if (boat.speed > boat.targetSpeed) { + boat.speed = max(boat.speed - accel, boat.targetSpeed) + } + + val entity = boat.entity + val diff = ((boat.targetAngle - entity.angle + HALF_ANGLE) and ANGLE_MASK) - HALF_ANGLE + if (diff != 0) { + val turn = diff.coerceIn(-TURN_RATE_PER_TICK, TURN_RATE_PER_TICK) + entity.updateAngle((entity.angle + turn) and ANGLE_MASK) + } + + if (boat.speed != 0) { + val theta = entity.angle * TWO_PI / ANGLE_FULL + val dx = quantize(boat.speed * -sin(theta)) + val dz = quantize(boat.speed * -cos(theta)) + if (dx != 0 || dz != 0) { + entity.updateCoord( + level = entity.projectedLevel, + fineX = entity.fineX + dx, + fineZ = entity.fineZ + dz, + teleport = false, + ) + } + } + } + + private fun quantize(v: Double): Int = + if (v >= 0) { + (v / VELOCITY_QUANTUM + 0.5).toInt() * VELOCITY_QUANTUM + } else { + -((-v / VELOCITY_QUANTUM + 0.5).toInt() * VELOCITY_QUANTUM) + } + + private companion object { + private const val ANGLE_FULL = 2048 + private const val ANGLE_MASK = ANGLE_FULL - 1 + private const val HALF_ANGLE = ANGLE_FULL / 2 + private const val TURN_RATE_PER_TICK = 128 + private const val VELOCITY_QUANTUM = 32 + private const val TWO_PI = 2.0 * PI + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematicsScript.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematicsScript.kt new file mode 100644 index 000000000..e36254230 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatKinematicsScript.kt @@ -0,0 +1,15 @@ +package org.rsmod.content.skills.sailing + +import jakarta.inject.Inject +import org.rsmod.api.game.process.GameLifecycle +import org.rsmod.api.script.onEvent +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class BoatKinematicsScript +@Inject +constructor(private val kinematics: BoatKinematics) : PluginScript() { + override fun ScriptContext.startup() { + onEvent { kinematics.tick() } + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt index 69b98c49f..3fad21ef7 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatManager.kt @@ -3,16 +3,20 @@ package org.rsmod.content.skills.sailing import com.github.michaelbull.logging.InlineLogger import jakarta.inject.Inject import jakarta.inject.Singleton +import org.rsmod.api.player.output.InteractionModes import org.rsmod.api.player.output.mes import org.rsmod.api.registry.worldentity.WorldEntityRegistry import org.rsmod.api.registry.worldentity.WorldEntityRegistryResult import org.rsmod.api.registry.worldentity.isSuccess +import org.rsmod.api.repo.loc.LocRepository import org.rsmod.api.repo.region.RegionRepository import org.rsmod.api.repo.region.RegionTemplate import org.rsmod.game.entity.Player import org.rsmod.game.entity.PlayerList import org.rsmod.game.entity.WorldEntity import org.rsmod.game.entity.util.PathingEntityCommon +import org.rsmod.game.loc.LocAngle +import org.rsmod.game.loc.LocShape import org.rsmod.map.CoordGrid import org.rsmod.routefinder.collision.CollisionFlagMap @@ -21,6 +25,7 @@ class BoatManager @Inject constructor( private val regionRepo: RegionRepository, + private val locRepo: LocRepository, private val worldEntityRegistry: WorldEntityRegistry, private val playerList: PlayerList, private val collision: CollisionFlagMap, @@ -29,6 +34,9 @@ constructor( private val boats = HashMap() + val all: Collection + get() = boats.values + fun spawn(type: BoatType, level: Int, fineX: Int, fineZ: Int, angle: Int = 0): Boat? { val template = RegionTemplate.create { @@ -65,10 +73,24 @@ constructor( return null } val boat = Boat(type, entity, region) + furnish(boat) boats[entity.slotId] = boat return boat } + private fun furnish(boat: Boat) { + val southWest = boat.region.southWest + for (deckLoc in boat.type.deckLocs) { + locRepo.add( + CoordGrid(southWest.x + deckLoc.dx, southWest.z + deckLoc.dz, deckLoc.level), + deckLoc.loc, + Int.MAX_VALUE, + LocAngle.West, + deckLoc.shape, + ) + } + } + fun spawnAtDock(type: BoatType, dock: Dock): Boat? { val boat = spawn( @@ -100,11 +122,30 @@ constructor( fun boatOf(player: Player): Boat? = boatAt(player.coords) + fun releaseHelm(player: Player) { + val boat = boatOf(player) ?: return + if (boat.helmsman != player) { + return + } + InteractionModes.resetInteractionMode(player, boat.entity.slotId) + InteractionModes.setInteractionMode( + player, + InteractionModes.WORLD_DEFAULT, + InteractionModes.TILE_MODE_WALK, + InteractionModes.ENTITY_MODE_ALL, + ) + player.helmLockedIn = 0 + boat.helmsman = null + boat.moveMode = SailingMoveModes.STOPPED + boat.targetSpeed = 0 + } + private fun evacuate(boat: Boat) { for (player in playerList) { if (!boat.entity.contains(player.coords)) { continue } + releaseHelm(player) PathingEntityCommon.telejump(player, collision, player.lastKnownNormalCoord) player.aboardPlayerBoat = 0 player.mes("You are returned to shore.") diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt index 83bed67dc..1bd299b08 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/BoatType.kt @@ -12,6 +12,13 @@ data class BoatType( val boardDestDz: Int, val dockFineDx: Int, val dockFineDz: Int, + val baseSpeed: Int, + val speedCap: Int, + val acceleration: Int, + val helmPlayerGrab: String, + val helmSynthGrab: Int, + val helmSynthRelease: Int, + val deckLocs: List, ) object BoatTypes { @@ -28,6 +35,13 @@ object BoatTypes { boardDestDz = 4, dockFineDx = 0, dockFineDz = 0, + baseSpeed = 192, + speedCap = 320, + acceleration = 64, + helmPlayerGrab = "seq.human_sailing_alpha_helm_raft01_active01", + helmSynthGrab = 10792, + helmSynthRelease = 10793, + deckLocs = DeckLocs.RAFT, ) val SKIFF = @@ -43,6 +57,13 @@ object BoatTypes { boardDestDz = 4, dockFineDx = 128, dockFineDz = 0, + baseSpeed = 192, + speedCap = 384, + acceleration = 64, + helmPlayerGrab = "seq.human_sailing_alpha_helm_small01_active01", + helmSynthGrab = 10807, + helmSynthRelease = 10808, + deckLocs = DeckLocs.SKIFF, ) val SLOOP = @@ -58,6 +79,13 @@ object BoatTypes { boardDestDz = 10, dockFineDx = 192, dockFineDz = 0, + baseSpeed = 192, + speedCap = 448, + acceleration = 64, + helmPlayerGrab = "seq.human_sailing_helm_3x8_active01", + helmSynthGrab = 10807, + helmSynthRelease = 10808, + deckLocs = DeckLocs.SLOOP, ) val all = listOf(RAFT, SKIFF, SLOOP) diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLoc.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLoc.kt new file mode 100644 index 000000000..1f2b9bd3e --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLoc.kt @@ -0,0 +1,11 @@ +package org.rsmod.content.skills.sailing + +import org.rsmod.game.loc.LocShape + +data class DeckLoc( + val loc: String, + val dx: Int, + val dz: Int, + val level: Int, + val shape: LocShape, +) diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLocs.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLocs.kt new file mode 100644 index 000000000..62ac0fe5c --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/DeckLocs.kt @@ -0,0 +1,60 @@ +package org.rsmod.content.skills.sailing + +import org.rsmod.game.loc.LocShape + +object DeckLocs { + val RAFT = + listOf( + DeckLoc("loc.sailing_boat_steering_kandarin_1x3_wood", 3, 4, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_1x3_wood", 3, 3, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_1x3_linen", 3, 5, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_cargo_hold_regular_raft", 3, 2, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.invisible_type0_nonblocking", 2, 3, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_gulls", 4, 3, 1, LocShape.GroundDecor), + DeckLoc("loc.bgsound_sailing_ocean_water_loop_01", 2, 4, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_crashing_waves", 4, 4, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 2, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 2, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 5, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 5, 1, LocShape.GroundDecor), + ) + + val SKIFF = + listOf( + DeckLoc("loc.sailing_keel_kandarin_2x5_bronze", 2, 1, 0, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_skiff_trim_wooden", 1, 1, 0, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_steering_kandarin_2x5_wood", 4, 6, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_2x5_wood", 4, 4, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_2x5_linen", 4, 5, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_cargo_hold_regular_2x5", 4, 1, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.invisible_type0_nonblocking", 2, 3, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_gulls", 5, 3, 1, LocShape.GroundDecor), + DeckLoc("loc.bgsound_sailing_ocean_water_loop_01", 2, 4, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_crashing_waves", 5, 4, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 2, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 5, 2, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 5, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 5, 5, 1, LocShape.GroundDecor), + ) + + val SLOOP = + listOf( + DeckLoc("loc.sailing_keel_kandarin_3x8_bronze", 1, 3, 0, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sloop_trim_wooden", 1, 2, 0, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_steering_kandarin_3x8_wood", 3, 11, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_3x8_wood", 4, 10, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_sail_kandarin_3x8_linen", 4, 11, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.sailing_boat_cargo_hold_regular_large", 3, 4, 1, LocShape.CentrepieceStraight), + DeckLoc("loc.invisible_type0_nonblocking", 2, 5, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 5, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 6, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 6, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 7, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 7, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 2, 8, 1, LocShape.GroundDecor), + DeckLoc("loc.invisible_type0_nonblocking", 4, 8, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_gulls", 4, 10, 1, LocShape.GroundDecor), + DeckLoc("loc.bgsound_sailing_ocean_water_loop_01", 2, 9, 1, LocShape.GroundDecor), + DeckLoc("loc.randomsound_ardent_ocean_crashing_waves", 4, 9, 1, LocShape.GroundDecor), + ) +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt index 41b76a916..62b899591 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/GangplankEvents.kt @@ -28,6 +28,7 @@ constructor(private val boats: BoatManager) : PluginScript() { onOpLoc1("loc.sailing_gangplank_disembark") { val dock = Docks.nearest(it.loc.coords) val dest = dock?.returnTile ?: player.lastKnownNormalCoord + boats.releaseHelm(player) player.aboardPlayerBoat = 0 telejump(dest, TeleportType.Exempt) if (dock != null) { diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/HelmEvents.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/HelmEvents.kt new file mode 100644 index 000000000..a8e6344c2 --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/HelmEvents.kt @@ -0,0 +1,131 @@ +package org.rsmod.content.skills.sailing + +import jakarta.inject.Inject +import org.rsmod.api.player.events.SailingEvent +import org.rsmod.api.player.output.InteractionModes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onEvent +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class HelmEvents +@Inject +constructor(private val boats: BoatManager) : PluginScript() { + override fun ScriptContext.startup() { + for (loc in HELM_IDLE_LOCS) { + onOpLoc1(loc) { navigate() } + } + for (loc in HELM_IN_USE_LOCS) { + onOpLoc1(loc) { stopNavigating() } + } + onEvent { steer(player, heading) } + } + + private fun ProtectedAccess.navigate() { + val boat = boats.boatOf(player) ?: return + if (boat.helmsman != null || player.helmLockedIn != 0) { + return + } + boat.helmsman = player + boat.moveMode = SailingMoveModes.HELM_IDLE + player.helmLockedIn = LOCKEDIN_NAVIGATING + InteractionModes.setInteractionMode( + player, + InteractionModes.WORLD_DEFAULT, + InteractionModes.TILE_MODE_HEADING, + InteractionModes.ENTITY_MODE_ALL, + ) + InteractionModes.setInteractionMode( + player, + boat.entity.slotId, + InteractionModes.TILE_MODE_WALK, + InteractionModes.ENTITY_MODE_ALL, + ) + anim(boat.type.helmPlayerGrab) + soundSynth(boat.type.helmSynthGrab) + } + + private fun ProtectedAccess.stopNavigating() { + val boat = boats.boatOf(player) ?: return + if (boat.helmsman != player) { + if (boat.helmsman == null && player.helmLockedIn != 0) { + player.helmLockedIn = 0 + } + return + } + boats.releaseHelm(player) + resetAnim() + soundSynth(boat.type.helmSynthRelease) + } + + private fun steer(player: Player, heading: Int) { + val boat = boats.boatOf(player) ?: return + if (boat.helmsman != player || player.helmLockedIn != LOCKEDIN_NAVIGATING) { + return + } + if (boat.moveMode == SailingMoveModes.HELM_IDLE) { + boat.moveMode = SailingMoveModes.FULL + boat.targetSpeed = boat.type.baseSpeed + boat.targetAngle = boat.entity.angle + return + } + boat.targetAngle = heading * HEADING_TO_ANGLE + } + + private companion object { + private const val LOCKEDIN_NAVIGATING = 3 + private const val HEADING_TO_ANGLE = 128 + + private val HELM_IDLE_LOCS = + listOf( + "loc.sailing_boat_steering_kandarin_1x3_wood_idle", + "loc.sailing_boat_steering_kandarin_1x3_oak_idle", + "loc.sailing_boat_steering_kandarin_1x3_teak_idle", + "loc.sailing_boat_steering_kandarin_1x3_mahogany_idle", + "loc.sailing_boat_steering_kandarin_1x3_camphor_idle", + "loc.sailing_boat_steering_kandarin_1x3_ironwood_idle", + "loc.sailing_boat_steering_kandarin_1x3_rosewood_idle", + "loc.sailing_boat_steering_kandarin_2x5_wood_idle", + "loc.sailing_boat_steering_kandarin_2x5_oak_idle", + "loc.sailing_boat_steering_kandarin_2x5_teak_idle", + "loc.sailing_boat_steering_kandarin_2x5_mahogany_idle", + "loc.sailing_boat_steering_kandarin_2x5_camphor_idle", + "loc.sailing_boat_steering_kandarin_2x5_ironwood_idle", + "loc.sailing_boat_steering_kandarin_2x5_rosewood_idle", + "loc.sailing_boat_steering_kandarin_3x8_wood_idle", + "loc.sailing_boat_steering_kandarin_3x8_oak_idle", + "loc.sailing_boat_steering_kandarin_3x8_teak_idle", + "loc.sailing_boat_steering_kandarin_3x8_mahogany_idle", + "loc.sailing_boat_steering_kandarin_3x8_camphor_idle", + "loc.sailing_boat_steering_kandarin_3x8_ironwood_idle", + "loc.sailing_boat_steering_kandarin_3x8_rosewood_idle", + ) + + private val HELM_IN_USE_LOCS = + listOf( + "loc.sailing_boat_steering_kandarin_1x3_wood_in_use", + "loc.sailing_boat_steering_kandarin_1x3_oak_in_use", + "loc.sailing_boat_steering_kandarin_1x3_teak_in_use", + "loc.sailing_boat_steering_kandarin_1x3_mahogany_in_use", + "loc.sailing_boat_steering_kandarin_1x3_camphor_in_use", + "loc.sailing_boat_steering_kandarin_1x3_ironwood_in_use", + "loc.sailing_boat_steering_kandarin_1x3_rosewood_in_use", + "loc.sailing_boat_steering_kandarin_2x5_wood_in_use", + "loc.sailing_boat_steering_kandarin_2x5_oak_in_use", + "loc.sailing_boat_steering_kandarin_2x5_teak_in_use", + "loc.sailing_boat_steering_kandarin_2x5_mahogany_in_use", + "loc.sailing_boat_steering_kandarin_2x5_camphor_in_use", + "loc.sailing_boat_steering_kandarin_2x5_ironwood_in_use", + "loc.sailing_boat_steering_kandarin_2x5_rosewood_in_use", + "loc.sailing_boat_steering_kandarin_3x8_wood_in_use", + "loc.sailing_boat_steering_kandarin_3x8_oak_in_use", + "loc.sailing_boat_steering_kandarin_3x8_teak_in_use", + "loc.sailing_boat_steering_kandarin_3x8_mahogany_in_use", + "loc.sailing_boat_steering_kandarin_3x8_camphor_in_use", + "loc.sailing_boat_steering_kandarin_3x8_ironwood_in_use", + "loc.sailing_boat_steering_kandarin_3x8_rosewood_in_use", + ) + } +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt index e00052bc1..bd4b5a6ef 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingDebugCommands.kt @@ -34,6 +34,18 @@ constructor( "(ex: ::moveboat 2 0 or ::moveboat 5 5 512 1)" cheat(::moveBoat) } + onCommand("setheading") { + desc = "Set the last debug boat's target heading (0-2047)" + requiredRights = Rights.ADMINISTRATOR + invalidArgs = "Use as ::setheading angle (0=S 512=W 1024=N 1536=E)" + cheat(::setHeading) + } + onCommand("setspeed") { + desc = "Set the last debug boat's target speed in fine units per tick" + requiredRights = Rights.ADMINISTRATOR + invalidArgs = "Use as ::setspeed speed (ex: ::setspeed 192, 0 to stop)" + cheat(::setSpeed) + } onCommand("delboat") { desc = "Delete the last debug boat" requiredRights = Rights.ADMINISTRATOR @@ -95,9 +107,35 @@ constructor( teleport = jump, ) args.getOrNull(2)?.toInt()?.let(entity::updateAngle) + boat.targetAngle = entity.angle player.mes("Moved boat to ${entity.coords} (angle=${entity.angle}).") } + private fun setHeading(cheat: Cheat) = + with(cheat) { + val boat = lastSpawned + if (boat == null) { + player.mes("No debug boat spawned.") + return@with + } + boat.targetAngle = args[0].toInt() and WorldEntity.MAX_ANGLE + player.mes( + "Target heading set to ${boat.targetAngle} " + + "(current angle=${boat.entity.angle})." + ) + } + + private fun setSpeed(cheat: Cheat) = + with(cheat) { + val boat = lastSpawned + if (boat == null) { + player.mes("No debug boat spawned.") + return@with + } + boat.targetSpeed = args[0].toInt().coerceIn(0, boat.type.speedCap) + player.mes("Target speed set to ${boat.targetSpeed} (cap=${boat.type.speedCap}).") + } + private fun boardBoat(cheat: Cheat) = with(cheat) { val boat = lastSpawned @@ -117,6 +155,7 @@ constructor( with(cheat) { val dest = player.lastKnownNormalCoord protectedAccess.launch(player) { + boats.releaseHelm(player) player.aboardPlayerBoat = 0 player.mes("You disembark.") telejump(dest, TeleportType.Exempt) diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingMoveModes.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingMoveModes.kt new file mode 100644 index 000000000..14670af4d --- /dev/null +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingMoveModes.kt @@ -0,0 +1,9 @@ +package org.rsmod.content.skills.sailing + +object SailingMoveModes { + const val STOPPED = 0 + const val HALF = 1 + const val FULL = 2 + const val REVERSE = 3 + const val HELM_IDLE = 4 +} diff --git a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt index fb8901ec8..f21a9d2da 100644 --- a/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt +++ b/content/skills/sailing/src/main/kotlin/org/rsmod/content/skills/sailing/SailingVars.kt @@ -4,3 +4,5 @@ import org.rsmod.api.player.vars.intVarBit import org.rsmod.game.entity.Player internal var Player.aboardPlayerBoat by intVarBit("varbit.sailing_player_is_on_player_boat") + +internal var Player.helmLockedIn by intVarBit("varbit.sailing_boat_facility_lockedin")