And then you commit the rest of the Castle War

This commit is contained in:
dam 2026-02-18 23:00:30 +02:00
parent 357160befe
commit 5f34e94c96
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
20 changed files with 4717 additions and 118 deletions

View file

@ -1,11 +1,16 @@
package rs09.game.content.activity.castlewars
package content.minigame.castlewars
import content.minigame.castlewars.CastleWarsConstants.GAME_COOLDOWN_MINUTES
import content.minigame.castlewars.CastleWarsConstants.GAME_TIME_MINUTES
import content.minigame.castlewars.CastleWarsConstants.ROPE_DURATION_TICKS
import core.game.world.map.Location
import core.game.world.map.zone.ZoneBorders
import org.rs09.consts.Items
import org.rs09.consts.NPCs
import org.rs09.consts.Scenery
object CastleWars {
// Scenery IDs
const val joinSaradominTeamPortal: Int = Scenery.SARADOMIN_PORTAL_4387
const val joinZamorakTeamPortal: Int = Scenery.ZAMORAK_PORTAL_4388
@ -16,6 +21,85 @@ object CastleWars {
const val castleWaterTap: Int = Scenery.TAP_4482
const val cwSteppingStones: Int = Scenery.STEPPING_STONE_4411
// Stepping stone tile locations for flag auto-return check.
// If a flag is dropped on any of these tiles, it immediately returns to base.
fun isSteppingStoneTile(location: Location): Boolean {
val obj = core.game.world.map.RegionManager.getObject(location)
return obj != null && obj.id == cwSteppingStones
}
// Energy barriers
const val saradominEnergyBarrier: Int = 4469 // "Only the Saradomin team may pass."
const val zamorakEnergyBarrier: Int = 4470 // "Only the Zamorak team may pass."
// Exit portals - leave the game (in respawn room)
const val saradominExitPortal: Int = 4406 // "A portal to leave the Saradomin team."
const val zamorakExitPortal: Int = 4407 // "A portal to leave the Zamorak team."
// Flag stand scenery IDs
const val saradominFlagStand: Int = 4902 // Saradomin Standard with flag (Capture option)
const val zamorakFlagStand: Int = 4903 // Zamorak Standard with flag (Capture option)
const val emptyFlagStand1: Int = 4377 // Empty stand (flag taken) - Saradomin side
const val emptyFlagStand2: Int = 4378 // Empty stand (flag taken) - Zamorak side
const val emptyFlagStand3: Int = 4379 // Empty stand variant (unused)
// Dropped flag scenery IDs (flags dropped on ground as interactable scenery)
// 4900/4901 are standalone flag objects with "Take" option - used when flag is dropped
const val saradominDroppedFlagScenery: Int = 4900 // Saradomin flag dropped on ground (Take option)
const val zamorakDroppedFlagScenery: Int = 4901 // Zamorak flag dropped on ground (Take option)
val droppedFlagSceneryIds: IntArray = intArrayOf(saradominDroppedFlagScenery, zamorakDroppedFlagScenery)
// All flag stand IDs for interaction handlers (stands at castle top)
val allFlagStandIds: IntArray = intArrayOf(4902, 4903, 4377, 4378, 4379)
// Large doors - castle wall doors that can be attacked/repaired
val largeDoorIds: IntArray = intArrayOf(4423, 4424, 4427, 4428) // Closed door variants
val largeDoorOpenIds: IntArray = intArrayOf(4425, 4426, 4429, 4430) // Open door variants
// Small doors / Backdoors - can be locked/unlocked
const val saradominBackdoor: Int = 4465
const val zamorakBackdoor: Int = 4467
val backdoorIds: IntArray = intArrayOf(saradominBackdoor, zamorakBackdoor)
// Backdoor open variants (for autowalk door handling)
const val saradominBackdoorOpen: Int = 4466
const val zamorakBackdoorOpen: Int = 4468
val backdoorOpenIds: IntArray = intArrayOf(saradominBackdoorOpen, zamorakBackdoorOpen)
// Tunnel rocks/boulders - can be mined or collapsed
const val tunnelRocks: Int = 4437 // Fully collapsed rocks (blocking)
const val tunnelRocksWeakened: Int = 4438 // Weakened rocks (1 explosive hit, still blocking)
const val caveWall: Int = 4448 // Cave wall for causing collapse
// Catapults - can be operated, destroyed, and repaired
// Both 4381 and 4382 are operational catapults (different orientations in the map)
// Both 4385 and 4386 are damaged/destroyed catapults (have "Repair" option instead of "Operate")
const val catapult: Int = 4381 // Operational catapult variant 1
const val catapultAlt: Int = 4382 // Operational catapult variant 2 (different orientation)
const val catapultDamaged1: Int = 4385 // Damaged catapult variant 1
const val catapultDamaged2: Int = 4386 // Damaged catapult variant 2
val damagedCatapultIds: IntArray = intArrayOf(catapultDamaged1, catapultDamaged2)
val allCatapultIds: IntArray = intArrayOf(catapult, catapultAlt, catapultDamaged1, catapultDamaged2)
// Trapdoors - allow going down from 2nd floor to respawn room
const val saradominTrapdoor: Int = 4471 // Saradomin castle trapdoor
const val zamorakTrapdoor: Int = 4472 // Zamorak castle trapdoor
// Ladders and Staircases for vertical navigation
// Ladders - climb down from respawn floor to ground floor (plane 1 -> plane 0)
val ladderClimbDownIds: IntArray = intArrayOf(36349, 36365, 4911, 4912) // All Castle Wars ladders
// Staircases - climb down from respawn floor to ground floor (plane 1 -> plane 0)
val staircaseClimbDownIds: IntArray = intArrayOf(4415, 4416, 4417, 4418, 4419, 4420)
// Steps - climb up from respawn floor to upper floors and flag floor
// These go from plane 1 -> plane 2 -> plane 3 (flag floor)
val stepsClimbUpIds: IntArray = intArrayOf(36480, 36481, 36484, 36495, 36521, 36523, 36532, 36540)
// All vertical navigation scenery IDs combined for easy searching
val allClimbDownIds: IntArray = ladderClimbDownIds + staircaseClimbDownIds
val allClimbUpIds: IntArray = stepsClimbUpIds
// Map of the item table scenery id to the item id they give
val cwTableItemRewardMap = mapOf(
Scenery.TABLE_36573 to Items.TOOLKIT_4051, // Saradomin Toolkit Table
@ -33,7 +117,6 @@ object CastleWars {
Scenery.TABLE_36579 to Items.BANDAGES_4049, // Saradomin Bandages Table
Scenery.TABLE_36586 to Items.BANDAGES_4049 // Zamorak Bandages Table
)
val cwCastleBattlementsMap = mapOf(
Scenery.BATTLEMENTS_4446 to Scenery.BATTLEMENTS_36313,
Scenery.BATTLEMENTS_4447 to Scenery.BATTLEMENTS_36314
@ -51,17 +134,36 @@ object CastleWars {
const val cwBandages: Int = Items.BANDAGES_4049
const val cwToolkit: Int = Items.TOOLKIT_4051
const val cwBarricade: Int = Items.BARRICADE_4053
const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055
const val cwManualBook: Int = Items.CASTLEWARS_MANUAL_4055 // TODO: unused? what happened
const val cwTicketRewardCurrency: Int = Items.CASTLE_WARS_TICKET_4067
// Authoritative list of items to remove from players on game end or area exit.
// Includes all CW-specific equipment, supplies, and items obtained from CW supply sources.
// Does NOT include tinderbox. I think you can just steal them =)
val CW_ITEMS_TO_REMOVE = intArrayOf(
saradominFlag, // 4037 - Saradomin banner
zamorakFlag, // 4039 - Zamorak banner
saradominTeamHoodedCloak, // 4041 - Saradomin hooded cloak
zamorakTeamHoodedCloak, // 4042 - Zamorak hooded cloak
cwRock, // 4043 - Rock
cwExplosivePotion, // 4045 - Explosive potion
cwClimbingRope, // 4047 - Climbing rope
cwBandages, // 4049 - Bandages
cwToolkit, // 4051 - Toolkit
cwBarricade, // 4053 - Barricade
cwPickaxe, // 1265 - Bronze pickaxe
Items.BUCKET_1925, // Bucket (obtained from CW water tap area)
Items.BUCKET_OF_WATER_1929 // Bucket of water (filled at CW water tap)
)
// NPC IDs
const val sheep = NPCs.SHEEP_1529
const val imp = NPCs.IMP_1531
const val rabbit = NPCs.RABBIT_1530
const val unknownCwarsBarricade1 = NPCs.BARRICADE_1532
const val unknownCwarsBarricadeOnFire = NPCs.BARRICADE_1533
const val unknownCwarsBarricade2 = NPCs.BARRICADE_1534
const val unknownCwarsBarricade3 = NPCs.BARRICADE_1535
const val saradominBarricadeNpc = NPCs.BARRICADE_1532 // Saradomin/Blue barricade
const val burningBarricadeNpc = NPCs.BARRICADE_1533 // Burning barricade (either team)
const val zamorakBarricadeNpc = NPCs.BARRICADE_1534 // Zamorak/Red barricade
const val barricadeVariantNpc = NPCs.BARRICADE_1535 // Additional barricade variant
// Locations
val lobbyBankArea: ZoneBorders = ZoneBorders(2440, 3092, 2444, 3086, 0)
@ -74,8 +176,7 @@ object CastleWars {
const val invFullMessage = "Your inventory is too full to hold any more "
// Ints
const val gameTimeMinutes = 20
const val gameCooldownMinutes = 5
const val ropeAliveTicks = -1 // TODO: Find amount of time until wall reverts to non rope state
const val gameTimeMinutes = GAME_TIME_MINUTES
const val gameCooldownMinutes = GAME_COOLDOWN_MINUTES
val ropeAliveTicks = ROPE_DURATION_TICKS
}

View file

@ -0,0 +1,22 @@
package content.minigame.castlewars
import core.game.node.entity.Entity
import core.game.node.entity.npc.NPC
import core.game.node.entity.npc.NPCBehavior
import org.rs09.consts.NPCs
/**
* NPCBehavior for Castle Wars barricades that prevents combat XP gain.
* Authentic RS behavior: "manual combat, no XP gained" when attacking barricades.
* This is registered for all barricade NPC IDs and returns 0.0 XP multiplier.
*/
class CastleWarsBarricadeBehavior : NPCBehavior(
NPCs.BARRICADE_1532, // Saradomin/Blue barricade
NPCs.BARRICADE_1533, // Burning barricade
NPCs.BARRICADE_1534, // Zamorak/Red barricade
NPCs.BARRICADE_1535 // Additional variant
) {
override fun getXpMultiplier(self: NPC, attacker: Entity): Double {
return 0.0
}
}

View file

@ -0,0 +1,85 @@
package content.minigame.castlewars
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.npc.AbstractNPC
import core.game.node.entity.npc.NPC
import core.game.world.map.Location
import org.rs09.consts.NPCs
/**
* Custom NPC class for Castle Wars barricades.
*
* - Block all entity movement through them (like Pest Control Brawlers) TODO: so... it sucks? shit
* - Never retaliate when attacked (this was scary man they squared up)
* - Never turn to face attackers or any direction
* - Cannot attack or move
*/
class CastleWarsBarricadeNPC : AbstractNPC {
constructor() : super(NPCs.BARRICADE_1532, null)
constructor(id: Int, location: Location) : super(id, location)
override fun init() {
super.init()
isWalks = false
isNeverWalks = true
isRespawn = false
properties.isRetaliating = false
isAggressive = false
}
override fun shouldPreventStacking(mover: Entity): Boolean {
return true
}
override fun faceLocation(location: Location?): Boolean {
return false
}
override fun face(entity: Entity?): Boolean {
return false
}
override fun faceTemporary(entity: Entity?, ticks: Int): Boolean {
return false
}
override fun attack(node: Node?) {
return
}
override fun construct(id: Int, location: Location, vararg objects: Any?): AbstractNPC {
return CastleWarsBarricadeNPC(id, location)
}
override fun getIds(): IntArray {
return intArrayOf(
NPCs.BARRICADE_1532, // Saradomin/Blue barricade
NPCs.BARRICADE_1533, // Burning barricade
NPCs.BARRICADE_1534, // Zamorak/Red barricade
NPCs.BARRICADE_1535 // Additional variant
)
}
companion object {
/**
* Spawns a Castle Wars barricade at the given location for the specified team.
*
* @param location The location to spawn the barricade
* @param team The team the barricade belongs to
* @return The spawned barricade NPC
*/
fun spawn(location: Location, team: CastleWarsTeam): NPC {
val barricadeId = when (team) {
CastleWarsTeam.SARADOMIN -> NPCs.BARRICADE_1532
CastleWarsTeam.ZAMORAK -> NPCs.BARRICADE_1534
}
val barricade = CastleWarsBarricadeNPC(barricadeId, location)
barricade.init()
return barricade
}
}
}

View file

@ -0,0 +1,108 @@
package content.minigame.castlewars
import content.minigame.castlewars.CastleWarsGameState.VARBIT_CATAPULT_X
import content.minigame.castlewars.CastleWarsGameState.VARBIT_CATAPULT_Y
import content.minigame.castlewars.CatapultConfig.ATTR_LOCATION
import content.minigame.castlewars.CatapultConfig.INTERFACE_ID
import core.api.*
import core.game.interaction.InterfaceListener
import core.game.node.entity.player.Player
import core.game.world.map.Location
class CastleWarsCatapultInterface : InterfaceListener {
private val renderer = CatapultInterfaceRenderer()
private val firingHandler = CatapultFiringHandler()
override fun defineInterfaceListeners() {
on(INTERFACE_ID) { player, _, _, buttonID, _, _ ->
handleButtonClick(player, buttonID)
}
onOpen(INTERFACE_ID) { player, _ ->
renderer.resetToDefaults(player)
true
}
onClose(INTERFACE_ID) { player, _ ->
player.removeAttribute(ATTR_LOCATION)
true
}
}
private fun handleButtonClick(player: Player, buttonId: Int): Boolean {
val team = getPlayerTeam(player) ?: return closeAndReturn(player, false)
val currentCoord = getCurrentCoordinate(player)
return when (val button = CatapultButton.fromId(buttonId)) {
CatapultButton.FIRE -> handleFire(player, currentCoord, team)
CatapultButton.CLOSE -> closeAndReturn(player, true)
CatapultButton.LEFT, CatapultButton.RIGHT, CatapultButton.UP, CatapultButton.DOWN -> {
handleCoordinateChange(player, currentCoord, button)
}
CatapultButton.UNKNOWN -> true // Ignore unknown buttons
}
}
private fun handleFire(player: Player, coord: Coordinate, team: CastleWarsTeam): Boolean {
if (!hasRocks(player)) {
sendMessage(player, "You don't have any rocks to fire!")
return true
}
val catapultLocation = getCatapultLocation(player) ?: run {
sendMessage(player, "Error: Catapult location not found.")
return true
}
if (!removeItem(player, CastleWars.cwRock)) {
sendMessage(player, "You don't have any rocks to fire!")
return true
}
val catapultTeam = CatapultTeam.fromCastleWarsTeam(team)
firingHandler.fire(player, coord, catapultTeam, catapultLocation)
closeInterface(player)
return true
}
private fun handleCoordinateChange(
player: Player,
currentCoord: Coordinate,
button: CatapultButton
): Boolean {
val newCoord = currentCoord.withDelta(button.deltaX, button.deltaY).clamp()
setVarbit(player, VARBIT_CATAPULT_X, newCoord.x)
setVarbit(player, VARBIT_CATAPULT_Y, newCoord.y)
renderer.updateMarkerPosition(player, newCoord)
renderer.updateDigitDisplay(player, newCoord)
return true
}
private fun getCurrentCoordinate(player: Player): Coordinate {
val x = getVarbit(player, VARBIT_CATAPULT_X)
val y = getVarbit(player, VARBIT_CATAPULT_Y)
return Coordinate(x, y)
}
private fun getPlayerTeam(player: Player): CastleWarsTeam? {
return CastleWarsGameState.getPlayerTeam(player)
}
private fun getCatapultLocation(player: Player): Location? {
return getAttribute(player, ATTR_LOCATION, null)
}
private fun hasRocks(player: Player): Boolean {
return player.inventory.contains(CastleWars.cwRock, 1)
}
private fun closeAndReturn(player: Player, result: Boolean): Boolean {
closeInterface(player)
return result
}
}

View file

@ -0,0 +1,106 @@
package content.minigame.castlewars
import content.minigame.castlewars.bots.CastleWarsBotSpawner
import core.api.Commands
import core.api.sendMessage
import core.game.interaction.Option
import core.game.system.command.Privilege
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.saradominPlayers
import content.minigame.castlewars.areas.CastleWarsGameArea.Companion.zamorakPlayers
import content.minigame.castlewars.areas.CastleWarsWaitingArea
/**
* Admin commands for Castle Wars development and testing.
*
* Commands:
* - ::cw start - Force starts a game if both teams have players
* - ::cw end - Ends the current game
* - ::cw addbot <sara|zammy> [amount] - Spawns bots for the specified team
*/
class CastleWarsCommands : Commands {
override fun defineCommands() {
define("cw", Privilege.ADMIN, "::cw <start|end|addbot>", "Castle Wars admin commands") { player, args ->
if (args.size < 2) {
sendMessage(player, "Usage: ::cw <start|end|addbot [sara|zammy] [amount]>")
return@define
}
when (args[1].lowercase()) {
"start" -> {
if (CastleWarsGameArea.ticksLeftInGame > 0) {
sendMessage(player, "A Castle Wars game is already in progress!")
return@define
}
val saraCount = CastleWarsWaitingArea.waitingSaradominPlayers.size
val zamCount = CastleWarsWaitingArea.waitingZamorakPlayers.size
if (saraCount == 0 || zamCount == 0) {
sendMessage(player, "Both teams need at least 1 player. Saradomin: $saraCount, Zamorak: $zamCount")
return@define
}
CastleWarsGameArea.startGame()
sendMessage(player, "Castle Wars game force started! Saradomin: $saraCount, Zamorak: $zamCount")
}
"end" -> {
if (CastleWarsGameArea.ticksLeftInGame <= 0) {
sendMessage(player, "No Castle Wars game is currently running.")
return@define
}
// Snapshot the sets before endGame() modifies them via teleport callbacks
val saraSnapshot = saradominPlayers.toSet()
val zamSnapshot = zamorakPlayers.toSet()
CastleWarsGameState.endGame()
CastleWarsItemHandler.clearAllBarricades()
for (p in saraSnapshot) {
p.interaction.remove(Option._P_ATTACK)
p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
}
for (p in zamSnapshot) {
p.interaction.remove(Option._P_ATTACK)
p.properties.teleportLocation = CastleWars.lobbyBankArea.randomWalkableLoc
}
saradominPlayers.clear()
zamorakPlayers.clear()
sendMessage(player, "Castle Wars game ended by admin command.")
}
"addbot" -> {
if (args.size < 3) {
sendMessage(player, "Usage: ::cw addbot <sara|zammy> [amount]")
return@define
}
val teamArg = args[2].lowercase()
val team = when {
teamArg.startsWith("sara") -> CastleWarsTeam.SARADOMIN
teamArg.startsWith("zam") -> CastleWarsTeam.ZAMORAK
else -> {
sendMessage(player, "Invalid team. Use 'sara' or 'zammy'.")
return@define
}
}
val amount = if (args.size >= 4) {
args[3].toIntOrNull() ?: run {
sendMessage(player, "Invalid amount. Must be a number.")
return@define
}
} else {
1
}
if (amount !in 1..20) {
sendMessage(player, "Amount must be between 1 and 20.")
return@define
}
for (i in 0 until amount) {
CastleWarsBotSpawner.spawnBot(team)
}
sendMessage(player, "Spawned $amount ${team.teamName} bot(s) at the Castle Wars lobby.")
}
else -> {
sendMessage(player, "Unknown subcommand. Use: start, end, or addbot")
}
}
}
}
}

View file

@ -0,0 +1,153 @@
package content.minigame.castlewars
/**
* Centralized constants for Castle Wars minigame.
* All magic numbers should be defined here with descriptive names.
*/
object CastleWarsConstants {
// ==================== Timing (in ticks) ====================
/** Grace period after picking up own flag before castle entry check (prevents instant return) */
const val FLAG_RETURN_GRACE_PERIOD_TICKS = 3
/** Ticks before a dropped flag automatically returns to its stand (~90 seconds) */
const val FLAG_AUTO_RETURN_TICKS = 150
/** Duration of climbing ropes before despawning (45 seconds at 600ms/tick) */
const val ROPE_DURATION_TICKS = 75
/** Burning barricade pulse interval (3 ticks = 1.8 seconds) */
const val BURNING_BARRICADE_PULSE_INTERVAL = 3
// ==================== Damage Values ====================
/** Self-damage when dropping explosive potion */
const val EXPLOSIVE_POTION_SELF_DAMAGE = 15
/** Door damage from explosive potion */
const val EXPLOSIVE_POTION_DOOR_DAMAGE = 25
/** Door repair amount per toolkit use */
const val TOOLKIT_DOOR_REPAIR = 10
/** Fire damage per tick to burning barricades */
const val BURNING_BARRICADE_DAMAGE_PER_TICK = 4
/** Maximum ticks a barricade can burn before destruction */
const val BURNING_BARRICADE_MAX_TICKS = 10
// ==================== Gameplay Limits ====================
/** Maximum barricades per team */
const val MAX_BARRICADES_PER_TEAM = 10
/** Maximum players per team */
const val MAX_PLAYERS_PER_TEAM = 25 // TODO: I dont believe this, where? Says who
/** Minimum players per team required to start a game */
const val MIN_PLAYERS_PER_TEAM = 2
/** Catapult targeting inaccuracy radius */
const val CATAPULT_INACCURACY_RADIUS = 2 // TODO: says who
/** Catapult impact damage radius */
const val CATAPULT_IMPACT_RADIUS = 3 // TODO: says who
/** Catapult minimum damage */
const val CATAPULT_MIN_DAMAGE = 10 // TODO: says who
/** Catapult maximum damage */
const val CATAPULT_MAX_DAMAGE = 20 // TODO: says who
// ==================== Coordinate Defaults ====================
/** Default X/Y coordinate for catapult targeting (center of 0-30 range) */
const val CATAPULT_DEFAULT_COORDINATE = 15 // TODO: says who
/** Maximum catapult coordinate value */
const val CATAPULT_MAX_COORDINATE = 30
// ==================== Bandage Healing ====================
/** Bandage heal percentage of max HP (10%) */
const val BANDAGE_HEAL_PERCENTAGE = 0.10
/** Bandage run energy restore percentage (30%) */
const val BANDAGE_RUN_ENERGY_RESTORE = 30.0
/** Bandage cooldown in ticks (1 tick = faster than food's 2 ticks) */
const val BANDAGE_COOLDOWN_TICKS = 1 // TODO: says who
// ==================== Castle Wars Bracelet ====================
/** Bracelet damage bonus multiplier against flag carriers (20% extra) */
const val BRACELET_FLAG_CARRIER_DAMAGE_BONUS = 0.20
/** Bracelet bandage heal percentage (15% instead of base 10%) */
const val BRACELET_BANDAGE_HEAL_PERCENTAGE = 0.15
// ==================== Energy Barrier ====================
/** Run energy drained when passing through an energy barrier */
const val ENERGY_BARRIER_DRAIN = 4.0
// ==================== Backdoor Pick-Lock ==================== // TODO: says who
/** Thieving level required for pick-lock (1 = no real requirement, all players can attempt) */
const val BACKDOOR_PICKLOCK_LEVEL = 1
/** Base pick-lock success chance (out of 100) at minimum level */
const val BACKDOOR_PICKLOCK_BASE_CHANCE = 50
/** Maximum pick-lock success chance (out of 100) */
const val BACKDOOR_PICKLOCK_MAX_CHANCE = 95
/** Ticks to lock player during pick-lock attempt */
const val BACKDOOR_PICKLOCK_TICKS = 3
// ==================== Cleanup Intervals ====================
/** How often to run inactive player cleanup (every 10 ticks = 6 seconds) */
const val PLAYER_CLEANUP_INTERVAL_TICKS = 10
// ==================== Door Health ====================
/** Initial door health percentage */
const val DOOR_INITIAL_HEALTH = 100
/** Door destroyed threshold (0 or below) */
const val DOOR_DESTROYED_THRESHOLD = 0
// ==================== Game Timing ====================
/** Game duration in minutes */
const val GAME_TIME_MINUTES = 20
/** Game cooldown in minutes */
const val GAME_COOLDOWN_MINUTES = 5
// ==================== Waiting Room ====================
/** Authentic waiting room countdown: GAME_COOLDOWN_MINUTES in ticks (5 min = 500 ticks) */
const val WAITING_ROOM_COUNTDOWN_TICKS = GAME_COOLDOWN_MINUTES * 100 // ticksPerMinute = 100
/** Dev-mode fast countdown for testing (~10 seconds) */
const val DEV_COUNTDOWN_TICKS = 17
/** Post-game shortened countdown in ticks (~2 minutes) for quick re-start */
const val POST_GAME_COUNTDOWN_TICKS = 200 // 2 minutes = 200 ticks
// ==================== Ticket Reward Requirements ====================
/** Minimum play time in minutes to be eligible for tickets */
const val MIN_PLAY_TIME_MINUTES = 10
/** Minimum team size for tickets to be awarded */
const val MIN_TEAM_SIZE_FOR_TICKETS = 5
// ==================== Player Attribute Keys ====================
/** Attribute key: world tick when player joined the current game */
const val ATTR_JOIN_TICK = "cw:join_tick"
/** Attribute key: world tick when player logged out (for grace period) */
const val ATTR_LOGOUT_TICK = "cw:logout_tick"
// ==================== Logout Grace Period ====================
/** Grace period after logout before removing player from team (2 minutes = 200 ticks) */
const val LOGOUT_GRACE_PERIOD_TICKS = 200
// ==================== Flag Steal Timer ====================
/** Time in ticks before teammates can steal the flag from a carrier (3 minutes = 300 ticks) */
const val FLAG_STEAL_TIMER_TICKS = 300
/** Player attribute key for tracking when enemy flag was picked up */
const val ATTR_FLAG_PICKUP_TICK = "cw:flag_pickup_tick"
}

View file

@ -0,0 +1,78 @@
package content.minigame.castlewars
import core.api.animate
import core.api.playAudio
import core.game.container.impl.EquipmentContainer
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
import core.game.node.scenery.Scenery
import core.game.system.task.Pulse
import org.rs09.consts.Sounds
/**
* Handles attacking the Castle Wars front doors.
* - Uses player's attack speed based on weapon
* - Calculates damage based on combat stats
* - Damages door health until destroyed or player stops
* TODO: 100% wild guesses and needs a fat refactoring
*/
class CastleWarsDoorAttackPulse(
private val player: Player,
private val door: Scenery,
private val doorTeam: CastleWarsTeam
) : Pulse() {
private var ticksUntilNextHit = 0
override fun pulse(): Boolean {
if (!player.isActive || CastleWarsGameState.isDoorDestroyed(doorTeam) || CastleWarsGameState.getPlayerTeam(
player
) == null
) {
return true
}
if (player.location.getDistance(door.location) > 2.0) {
return true
}
player.faceLocation(door.location)
if (ticksUntilNextHit <= 0) {
performAttack()
ticksUntilNextHit = getAttackSpeed()
}
ticksUntilNextHit--
return false
}
private fun performAttack() {
val animation = getAttackAnimation()
animate(player, animation)
val damage = calculateDamage()
CastleWarsGameState.damageDoor(doorTeam, damage)
playAudio(player, Sounds.WOODCUTTING_HIT_3038)
}
private fun getAttackSpeed(): Int {
val weapon = player.equipment.get(EquipmentContainer.SLOT_WEAPON)
return weapon?.definition?.getConfiguration("attack_speed", 4) ?: 4
}
private fun calculateDamage(): Int {
// Doors are weak to crush > slash > stab
val strengthLevel = player.skills.getLevel(Skills.STRENGTH)
val bonusType = player.properties.attackStyle.bonusType
val styleBonus = when (bonusType) {
2 -> 10 // Crush
1 -> 4 // Slash
0 -> 1 // Stab
else -> 3 // Default
} // Calculate damage: base from strength + style bonus
val damage =
(strengthLevel / 12) + styleBonus // Clamp to 1-25 range (max 25% of door health per hit) TODO: 100% inauthentic
return damage.coerceIn(1, 25)
}
private fun getAttackAnimation(): Int {
val attackAnim = player.properties.attackAnimation
return if (attackAnim != null && attackAnim.id > 0) attackAnim.id else 422
}
}

View file

@ -0,0 +1,195 @@
package content.minigame.castlewars
import core.api.sendMessage
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
import core.game.node.item.GroundItem
import core.game.world.map.Location
/**
* Handles flag interactions in Castle Wars:
* - Taking the enemy flag from their stand
* - Capturing a flag at your own stand
* - Picking up dropped flags
* - Returning your own dropped flag
*/
class CastleWarsFlagHandler : InteractionListener {
companion object {
// Zone borders for flag stand areas (top floor of each castle)
private val saradominFlagArea =
core.game.world.map.zone.ZoneBorders(Location.create(2426, 3071, 3), Location.create(2431, 3078, 3))
private val zamorakFlagArea =
core.game.world.map.zone.ZoneBorders(Location.create(2368, 3129, 3), Location.create(2373, 3136, 3))
/**
* Check if a location is in the Saradomin flag area
*/
fun isInSaradominFlagArea(location: Location): Boolean {
return saradominFlagArea.insideBorder(location)
}
/**
* Check if a location is in the Zamorak flag area
*/
fun isInZamorakFlagArea(location: Location): Boolean {
return zamorakFlagArea.insideBorder(location)
}
}
override fun defineListeners() { // Handle clicking on flag stands (both with and without flags)
on(CastleWars.allFlagStandIds, IntType.SCENERY, "capture") { player, node ->
if (!CastleWarsGameState.isGameActive) {
sendMessage(player, "There is no game in progress.")
return@on true
}
val playerTeam = CastleWarsGameState.getPlayerTeam(player)
if (playerTeam == null) {
sendMessage(player, "You are not in a Castle Wars game.")
return@on true
}
val sceneryId = node.id
val location = node.location
// 4902 = Saradomin flag stand with flag, 4377 = empty Saradomin stand
// 4903 = Zamorak flag stand with flag, 4378 = empty Zamorak stand
val isSaradominStand =
sceneryId == CastleWars.saradominFlagStand || sceneryId == CastleWars.emptyFlagStand1 || isInSaradominFlagArea(
location
)
val isZamorakStand =
sceneryId == CastleWars.zamorakFlagStand || sceneryId == CastleWars.emptyFlagStand2 || isInZamorakFlagArea(
location
)
val standTeam = when {
isSaradominStand -> CastleWarsTeam.SARADOMIN
isZamorakStand -> CastleWarsTeam.ZAMORAK
else -> {
sendMessage(player, "You can't interact with this.")
return@on true
}
}
val isEmptyStand = sceneryId in intArrayOf(
CastleWars.emptyFlagStand1, CastleWars.emptyFlagStand2, CastleWars.emptyFlagStand3
)
if (CastleWarsGameState.isCarryingFlag(player)) {
if (standTeam == playerTeam) {
val ownFlagState = if (playerTeam == CastleWarsTeam.SARADOMIN) {
CastleWarsGameState.saradominFlagState
} else {
CastleWarsGameState.zamorakFlagState
}
if (ownFlagState != FlagState.SAFE) {
sendMessage(player, "Your team's flag must be at its stand to capture!")
return@on true
}
CastleWarsGameState.captureFlag(player)
} else {
sendMessage(player, "You need to take this flag to your own flag stand!")
}
return@on true
}
if (isEmptyStand) {
sendMessage(player, "The flag isn't here!")
return@on true
}
if (standTeam != playerTeam) {
val flagState = if (standTeam == CastleWarsTeam.SARADOMIN) {
CastleWarsGameState.saradominFlagState
} else {
CastleWarsGameState.zamorakFlagState
}
if (flagState != FlagState.SAFE) {
sendMessage(player, "The flag isn't here!")
return@on true
}
CastleWarsGameState.pickUpFlag(player, standTeam)
} else {
sendMessage(player, "This is your team's flag stand.")
}
return@on true
}
// Intercept flag unequip - drop on ground instead of adding to inventory
// When player clicks "Remove" on the flag in equipment screen, or tries to equip another weapon,
// the flag should drop at their feet instead of going to their inventory.
onUnequip(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag)) { player, _ ->
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
return@onUnequip false
}
on(CastleWars.droppedFlagSceneryIds, IntType.SCENERY, "take") { player, node ->
if (!CastleWarsGameState.isGameActive) {
sendMessage(player, "There is no game in progress.")
return@on true
}
val playerTeam = CastleWarsGameState.getPlayerTeam(player)
if (playerTeam == null) {
sendMessage(player, "You are not in a Castle Wars game.")
return@on true
}
val scenery = node as? core.game.node.scenery.Scenery ?: return@on true
val flagTeam = when (scenery.id) {
CastleWars.saradominDroppedFlagScenery -> CastleWarsTeam.SARADOMIN
CastleWars.zamorakDroppedFlagScenery -> CastleWarsTeam.ZAMORAK
else -> return@on true
}
val isValidDroppedFlag = when (flagTeam) {
CastleWarsTeam.SARADOMIN -> CastleWarsGameState.saradominDroppedFlagScenery?.location == scenery.location
CastleWarsTeam.ZAMORAK -> CastleWarsGameState.zamorakDroppedFlagScenery?.location == scenery.location
}
if (!isValidDroppedFlag) {
sendMessage(player, "The flag isn't here!")
return@on true
}
if (flagTeam == playerTeam) {
if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) { // Message already sent by pickUpOwnFlag
}
} else {
if (CastleWarsGameState.pickUpFlag(player, flagTeam)) { // Message already sent by pickUpFlag
}
}
return@on true
}
// Legacy: Handle picking up dropped flags from the ground (ground items)
// This is kept for backwards compatibility but scenery is the primary method now
// TODO: cut
on(intArrayOf(CastleWars.saradominFlag, CastleWars.zamorakFlag), IntType.GROUNDITEM, "take") { player, node ->
if (!CastleWarsGameState.isGameActive) {
return@on false
}
val playerTeam = CastleWarsGameState.getPlayerTeam(player) ?: return@on false
val groundItem = node as? GroundItem ?: return@on false
val flagTeam = when (groundItem.id) {
CastleWars.saradominFlag -> CastleWarsTeam.SARADOMIN
CastleWars.zamorakFlag -> CastleWarsTeam.ZAMORAK
else -> return@on false
}
val isDroppedFlag = when (flagTeam) {
CastleWarsTeam.SARADOMIN -> CastleWarsGameState.saradominFlagState == FlagState.DROPPED
CastleWarsTeam.ZAMORAK -> CastleWarsGameState.zamorakFlagState == FlagState.DROPPED
}
if (!isDroppedFlag) {
return@on false
}
if (flagTeam == playerTeam) {
if (CastleWarsGameState.pickUpOwnFlag(player, flagTeam)) {
return@on true
}
} else {
if (CastleWarsGameState.pickUpFlag(player, flagTeam)) {
return@on true
}
}
return@on true
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsRespawnArea
import core.api.*
import core.game.node.entity.Entity
import core.game.node.entity.impl.PulseManager
import core.game.node.entity.player.Player
import core.game.system.timer.impl.Poison
import core.game.world.map.Location
import core.game.world.map.RegionManager.getLocalNpcs
import core.game.world.map.path.Pathfinder
import core.game.world.map.zone.*
/**
* MapZone for Castle Wars game area that handles death and respawn.
* This is registered in addition to the MapArea to provide death handling,
* since MapArea doesn't support overriding the death method.
*/
class CastleWarsGameZone : MapZone("CastleWarsGame", true, ZoneRestriction.RANDOM_EVENTS), StartupListener {
companion object {
private val saradominStandardFloor = ZoneBorders(Location.create(2426, 3073, 3), Location.create(2430, 3077, 3))
private val zamorakStandardFloor = ZoneBorders(Location.create(2373, 3134, 3), Location.create(2369, 3130, 3))
private val saradominUpperFloor = ZoneBorders(Location.create(2431, 3080, 2), Location.create(2423, 3072, 2))
private val zamorakUpperFloor = ZoneBorders(Location.create(2368, 3127, 2), Location.create(2376, 3135, 2))
private val saradominFloor = ZoneBorders(Location.create(2420, 3072, 1), Location.create(2431, 3083, 1))
private val zamorakFloor = ZoneBorders(Location.create(2379, 3135, 1), Location.create(2368, 3124, 1))
private val battleField = ZoneBorders(Location.create(2368, 3135, 0), Location.create(2431, 3072, 0))
private val saradominTunnels = ZoneBorders(Location.create(2430, 9481, 0), Location.create(2385, 9504, 0))
private val zamorakTunnels = ZoneBorders(Location.create(2401, 9503, 0), Location.create(2366, 9529, 0))
private val centerTunnels = ZoneBorders(Location.create(2396, 9493, 0), Location.create(2405, 9515, 0))
val allBorders = arrayOf(
saradominStandardFloor,
zamorakStandardFloor,
saradominUpperFloor,
zamorakUpperFloor,
saradominFloor,
zamorakFloor,
battleField,
saradominTunnels,
zamorakTunnels,
centerTunnels
)
/**
* Handle Castle Wars death with poison preservation.
* Shared between CastleWarsGameZone.death() and CastleWarsGameArea.handleDeath().
* In Castle Wars, poison persists through death unlike normal safe deaths.
*/
fun handleCastleWarsDeath(player: Player): Boolean {
if (CastleWarsGameArea.ticksLeftInGame <= 0) return false
val team = CastleWarsGameState.getPlayerTeam(player) ?: return false
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
PulseManager.cancelDeathTask(player)
val poisonTimer = getTimer<Poison>(player)
val poisonSeverity: Int? = poisonTimer?.severity
val poisonSource: Entity? = poisonTimer?.damageSource
player.fullRestore()
if (poisonSeverity != null && poisonSeverity > 0 && poisonSource != null) {
registerTimer(player, spawnTimer<Poison>(poisonSource, poisonSeverity))
}
val respawnLoc = when (team) {
CastleWarsTeam.SARADOMIN -> CastleWarsRespawnArea.saradominRespawnRoom.randomWalkableLoc
CastleWarsTeam.ZAMORAK -> CastleWarsRespawnArea.zamorakRespawnRoom.randomWalkableLoc
}
player.properties.teleportLocation = respawnLoc
sendMessage(player, "You have been defeated and respawn at your team's base.")
return true
}
}
override fun startup() {
ZoneBuilder.configure(this)
for (border in allBorders) {
register(border)
}
}
override fun configure() {
}
override fun death(e: Entity, killer: Entity?): Boolean {
if (e !is Player) return false
return handleCastleWarsDeath(e)
}
override fun move(e: Entity, from: Location, to: Location): Boolean {
val dx = to.x - from.x
val dy = to.y - from.y
val isMovingUp = to.z > from.z
val isChangingZLevel = to.z != from.z
for (n in getLocalNpcs(e, 5)) {
if (n.isInvisible || n === e) {
continue
}
if (n.shouldPreventStacking(e)) {
val s1 = e.size()
val s2 = n.size()
val l = n.location
if (Pathfinder.isStandingIn(to.x, to.y, s1, s1, l.x, l.y, s2, s2)) {
if (isChangingZLevel && !isMovingUp) {
continue
}
return false
}
if (!isChangingZLevel && dx != 0 && dy != 0) {
val intermediateXOverlap = Pathfinder.isStandingIn(from.x + dx, from.y, s1, s1, l.x, l.y, s2, s2)
val intermediateYOverlap = Pathfinder.isStandingIn(
from.x, from.y + dy, s1, s1, l.x, l.y, s2, s2
)
if (intermediateXOverlap || intermediateYOverlap) {
return false
}
}
}
}
if (e !is Player) return true
return true
}
override fun getZoneType(): Int {
return ZoneType.CASTLE_WARS.id
}
}

View file

@ -0,0 +1,351 @@
package content.minigame.castlewars
import content.minigame.castlewars.CastleWarsConstants.BANDAGE_COOLDOWN_TICKS
import content.minigame.castlewars.CastleWarsConstants.BANDAGE_HEAL_PERCENTAGE
import content.minigame.castlewars.CastleWarsConstants.BANDAGE_RUN_ENERGY_RESTORE
import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_DAMAGE_PER_TICK
import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_MAX_TICKS
import content.minigame.castlewars.CastleWarsConstants.BURNING_BARRICADE_PULSE_INTERVAL
import content.minigame.castlewars.CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE
import content.minigame.castlewars.CastleWarsConstants.MAX_BARRICADES_PER_TEAM
import core.api.*
import core.game.interaction.Clocks
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
import core.game.node.entity.combat.ImpactHandler
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
import core.game.node.item.Item
import core.game.world.map.Location
import core.game.world.map.RegionManager
import org.rs09.consts.Animations
import org.rs09.consts.Items
import org.rs09.consts.NPCs
/**
* Handles Castle Wars item mechanics:
* - Bandages: Heal 10% of max HP, restore 30% run energy, can heal teammates
* - Barricades: Place barricade NPC with 5000 HP, max 10 per team TODO: 5000 HP BARRICADE HAHHAH FAT CHANCE. keeping that in for now
* - Explosive Potions: Instant-destroy barricades, 15% max HP self-damage on drop
*/
class CastleWarsItemHandler : InteractionListener {
companion object {
val saradominBarricades = mutableListOf<NPC>()
val zamorakBarricades = mutableListOf<NPC>()
val burningBarricadePulses = mutableMapOf<NPC, core.game.system.task.Pulse>()
fun getBarricadeCount(team: CastleWarsTeam): Int {
return when (team) {
CastleWarsTeam.SARADOMIN -> saradominBarricades.count { it.isActive }
CastleWarsTeam.ZAMORAK -> zamorakBarricades.count { it.isActive }
}
}
fun clearAllBarricades() {
burningBarricadePulses.values.forEach { it.stop() }
burningBarricadePulses.clear()
saradominBarricades.forEach { if (it.isActive) it.clear() }
zamorakBarricades.forEach { if (it.isActive) it.clear() }
saradominBarricades.clear()
zamorakBarricades.clear()
}
fun cleanupDeadBarricades() {
saradominBarricades.removeAll { !it.isActive }
zamorakBarricades.removeAll { !it.isActive }
}
fun dealExplosiveAoeDamage(attacker: Player, location: Location, radius: Int = 1) {
val attackerTeam = CastleWarsGameState.getPlayerTeam(attacker) ?: return
RegionManager.getLocalPlayers(location, radius)
.filter { target ->
target != attacker &&
target.isActive &&
CastleWarsGameState.getPlayerTeam(target) != null &&
CastleWarsGameState.getPlayerTeam(target) != attackerTeam
}
.forEach { target ->
val damage = CastleWarsConstants.EXPLOSIVE_POTION_SELF_DAMAGE
.coerceAtMost(target.skills.lifepoints)
target.impactHandler.manualHit(
attacker, damage, ImpactHandler.HitsplatType.NORMAL
)
sendMessage(target, "You are caught in an explosion!")
}
}
}
override fun defineListeners() {
on(CastleWars.cwBandages, IntType.ITEM, "Heal") { player, node ->
if (useBandage(player, player)) {
removeItem(player, node.asItem())
}
return@on true
}
onUseWith(IntType.PLAYER, CastleWars.cwBandages, -1) { player, used, with ->
val target = with as? Player ?: return@onUseWith false
val playerTeam = CastleWarsGameState.getPlayerTeam(player)
val targetTeam = CastleWarsGameState.getPlayerTeam(target)
if (playerTeam == null || targetTeam == null) {
sendMessage(player, "You can only use bandages in Castle Wars.")
return@onUseWith false
}
if (playerTeam != targetTeam) {
sendMessage(player, "You can only heal your teammates!")
return@onUseWith false
}
if (CastleWarsGameState.isCarryingFlag(target) && CastleWarsGameState.canTeammateStealFlag(target)) {
CastleWarsGameState.transferFlag(target, player)
return@onUseWith true
}
if (useBandage(player, target)) {
removeItem(player, used.asItem())
}
return@onUseWith true
}
on(CastleWars.cwBarricade, IntType.ITEM, "set-up") { player, node ->
if (!CastleWarsGameState.isGameActive) {
sendMessage(player, "You can only set up barricades during a game.")
return@on false
}
val team = CastleWarsGameState.getPlayerTeam(player)
if (team == null) {
sendMessage(player, "You're not in a Castle Wars game.")
return@on false
}
cleanupDeadBarricades()
if (getBarricadeCount(team) >= MAX_BARRICADES_PER_TEAM) {
sendMessage(
player,
"Your team already has the maximum number of barricades ($MAX_BARRICADES_PER_TEAM)."
)
return@on false
}
val nearbyBarricades = getLocalBarricades(player.location, 0)
if (nearbyBarricades.isNotEmpty()) {
sendMessage(player, "There's already a barricade here.")
return@on false
}
val barricade = CastleWarsBarricadeNPC.spawn(player.location, team)
barricade.skills.setStaticLevel(Skills.HITPOINTS, 50)
barricade.skills.lifepoints = 50
when (team) {
CastleWarsTeam.SARADOMIN -> saradominBarricades.add(barricade)
CastleWarsTeam.ZAMORAK -> zamorakBarricades.add(barricade)
}
removeItem(player, node.asItem())
animate(player, 827) // Place animation
sendMessage(player, "You set up a barricade.")
return@on true
}
onUseWith(
IntType.NPC, CastleWars.cwExplosivePotion, *intArrayOf(
NPCs.BARRICADE_1532, NPCs.BARRICADE_1533,
NPCs.BARRICADE_1534, NPCs.BARRICADE_1535
)
) { player, used, with ->
val barricade = with as? NPC ?: return@onUseWith false
if (!CastleWarsGameState.isGameActive) {
return@onUseWith false
}
if (barricade.id == NPCs.BARRICADE_1533) {
sendMessage(player, "You cannot blow up a burning barricade.")
return@onUseWith false
}
animate(player, Animations.HUMAN_BURYING_BONES_827)
playAudio(player, 2599)
sendGraphics(157, barricade.location)
barricade.skills.lifepoints = 0
barricade.clear()
cleanupDeadBarricades()
removeItem(player, used.asItem())
sendMessage(player, "The explosive potion destroys the barricade!")
dealExplosiveAoeDamage(player, barricade.location)
return@onUseWith true
}
on(CastleWars.cwExplosivePotion, IntType.ITEM, "drop") { player, node ->
if (!CastleWarsGameState.isGameActive) {
return@on false
}
player.impactHandler.manualHit(
player,
EXPLOSIVE_POTION_SELF_DAMAGE.coerceAtMost(player.skills.lifepoints),
ImpactHandler.HitsplatType.NORMAL
)
animate(player, Animations.HUMAN_BURYING_BONES_827)
playAudio(player, 2599) // Fire/explosion sound
removeItem(player, node.asItem())
sendMessage(player, "The potion explodes! You take damage.")
val nearbyBarricades = getLocalBarricades(player.location, 1)
for (barricade in nearbyBarricades) {
if (barricade.id == NPCs.BARRICADE_1533) continue
sendGraphics(157, barricade.location)
barricade.skills.lifepoints = 0
barricade.clear()
}
cleanupDeadBarricades()
dealExplosiveAoeDamage(player, player.location)
return@on true
}
onUseWith(
IntType.NPC, Items.TINDERBOX_590, *intArrayOf(
NPCs.BARRICADE_1532, NPCs.BARRICADE_1533,
NPCs.BARRICADE_1534, NPCs.BARRICADE_1535
)
) { player, _, with ->
val barricade = with as? NPC ?: return@onUseWith false
if (!CastleWarsGameState.isGameActive) {
return@onUseWith false
}
if (barricade.id == NPCs.BARRICADE_1533) {
sendMessage(player, "The barricade is already on fire!")
return@onUseWith false
}
val burningId = NPCs.BARRICADE_1533
playAudio(player, 2599)
barricade.transform(burningId)
sendMessage(player, "You set the barricade on fire!")
val firePulse = object : core.game.system.task.Pulse(BURNING_BARRICADE_PULSE_INTERVAL) {
var tickCount = 0
override fun pulse(): Boolean {
if (!barricade.isActive || barricade.id != NPCs.BARRICADE_1533 || tickCount >= BURNING_BARRICADE_MAX_TICKS) {
burningBarricadePulses.remove(barricade)
if (barricade.isActive && barricade.id == NPCs.BARRICADE_1533) {
barricade.skills.lifepoints = 0
barricade.clear()
cleanupDeadBarricades()
}
return true
}
barricade.skills.lifepoints =
(barricade.skills.lifepoints - BURNING_BARRICADE_DAMAGE_PER_TICK).coerceAtLeast(0)
tickCount++
return false
}
}
burningBarricadePulses[barricade] = firePulse
submitWorldPulse(firePulse)
return@onUseWith true
}
onUseWith(IntType.NPC, Items.BUCKET_OF_WATER_1929, NPCs.BARRICADE_1533) { player, used, with ->
val barricade = with as? NPC ?: return@onUseWith false
if (!CastleWarsGameState.isGameActive) {
return@onUseWith false
}
val originalId = if (saradominBarricades.contains(barricade)) {
NPCs.BARRICADE_1532
} else {
NPCs.BARRICADE_1534
}
barricade.transform(originalId)
replaceSlot(player, used.asItem().slot, Item(Items.BUCKET_1925))
playAudio(player, 2401)
sendMessage(player, "You extinguish the fire on the barricade.")
return@onUseWith true
}
}
private fun useBandage(user: Player, target: Player): Boolean {
if (CastleWarsGameState.isCarryingOwnFlag(target)) {
if (user == target) {
sendMessage(user, "You can't heal while carrying your team's flag!")
} else {
sendMessage(user, "You can't heal a player carrying their team's flag!")
}
return false
}
if (target.clocks[Clocks.NEXT_EAT] >= getWorldTicks()) {
return false
}
target.clocks[Clocks.NEXT_EAT] = getWorldTicks() + BANDAGE_COOLDOWN_TICKS
val maxHp = target.skills.getStaticLevel(Skills.HITPOINTS)
val healPercent = if (user.hasCastleWarsBracelet()) {
CastleWarsConstants.BRACELET_BANDAGE_HEAL_PERCENTAGE
} else {
BANDAGE_HEAL_PERCENTAGE
}
val healAmount = (maxHp * healPercent).toInt().coerceAtLeast(1)
val currentHp = target.skills.lifepoints
val newHp = (currentHp + healAmount).coerceAtMost(maxHp)
target.skills.lifepoints = newHp
// Restore run energy
val currentEnergy = target.settings.runEnergy
target.settings.runEnergy = (currentEnergy + BANDAGE_RUN_ENERGY_RESTORE).coerceAtMost(100.0)
curePoison(target)
if (user == target) {
sendMessage(user, "You apply the bandage and feel better.")
} else {
sendMessage(user, "You apply the bandage to ${target.username}.")
sendMessage(target, "${user.username} applies a bandage to you.")
}
return true
}
private fun getLocalBarricades(location: Location, radius: Int): List<NPC> {
val barricadeIds = intArrayOf(
NPCs.BARRICADE_1532, NPCs.BARRICADE_1533,
NPCs.BARRICADE_1534, NPCs.BARRICADE_1535
)
return RegionManager.getLocalNpcs(location, radius)
.filter { it.id in barricadeIds && it.isActive }
}
}

View file

@ -1,3 +1,5 @@
package content.minigame.castlewars
import core.api.*
import core.game.node.entity.player.Player
@ -7,16 +9,8 @@ import core.game.node.entity.player.Player
*/
object CastleWarsOverlay {
@JvmStatic
fun sendLobbyUpdate(player: Player, bothTeamsHavePlayers: Boolean, gameStartMinutes: Int) {
setVarp(player, 380, if (bothTeamsHavePlayers) gameStartMinutes else 0)
}
@JvmStatic
fun sendGameUpdate(player: Player) {
// Todo - Figure out underground mine/etc
setVarbit(player, 143, 0) // Flag status - safe = 0, taken = 1, dropped = 2
setVarbit(player, 145, 5) // Saradomin's score
setVarbit(player, 153, 0) // Flag status - safe = 0, taken = 1, dropped = 2
setVarbit(player, 155, 7) // Zamorak's score
fun sendLobbyUpdate(player: Player, enoughPlayers: Boolean, gameStartMinutes: Int) {
// When not enough players, send -1 to hide the timer display
setVarp(player, 380, if (enoughPlayers) gameStartMinutes else -1)
}
}

View file

@ -0,0 +1,78 @@
package content.minigame.castlewars
import core.api.ShutdownListener
import core.game.node.entity.player.Player
import core.game.world.map.Location
import core.game.world.map.RegionManager
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsRespawnArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
/**
* Handles Castle Wars cleanup when the server shuts down.
* Ensures all players in any Castle Wars area are properly removed
* so they don't end up in a zombie match state when they log back in.
*/
class CastleWarsShutdownHandler : ShutdownListener {
override fun shutdown() {
val allTrackedPlayers = mutableSetOf<Player>()
allTrackedPlayers += CastleWarsGameArea.saradominPlayers
allTrackedPlayers += CastleWarsGameArea.zamorakPlayers
allTrackedPlayers += CastleWarsWaitingArea.waitingSaradominPlayers
allTrackedPlayers += CastleWarsWaitingArea.waitingZamorakPlayers
val respawnBorders = arrayOf(
CastleWarsRespawnArea.saradominRespawnRoom,
CastleWarsRespawnArea.zamorakRespawnRoom
)
for (border in respawnBorders) {
val centerX = (border.southWestX + border.northEastX) / 2
val centerY = (border.southWestY + border.northEastY) / 2
val centerLocation = Location.create(centerX, centerY, border.plane)
for (player in RegionManager.getLocalPlayers(centerLocation, 20)) {
if (border.insideBorder(player.location)) {
allTrackedPlayers.add(player)
}
}
}
for (border in CastleWarsGameArea.areaBorders) {
val centerX = (border.southWestX + border.northEastX) / 2
val centerY = (border.southWestY + border.northEastY) / 2
val plane = border.plane
val radius = maxOf(
kotlin.math.abs(border.northEastX - border.southWestX),
kotlin.math.abs(border.northEastY - border.southWestY)
) / 2 + 10
val centerLocation = Location.create(centerX, centerY, plane)
for (player in RegionManager.getLocalPlayers(centerLocation, radius)) {
if (border.insideBorder(player.location)) {
allTrackedPlayers.add(player)
}
}
}
for (player in allTrackedPlayers) {
if (CastleWarsGameState.isCarryingFlag(player)) {
CastleWarsGameState.dropFlag(player)
} else if (CastleWarsGameState.isCarryingOwnFlag(player)) {
CastleWarsGameState.dropOwnFlag(player)
}
CastleWarsGameState.restoreAndCleanupPlayer(player)
player.location = CastleWars.lobbyBankArea.randomWalkableLoc
}
CastleWarsItemHandler.clearAllBarricades()
CastleWarsGameState.resetGame()
CastleWarsGameArea.saradominPlayers.clear()
CastleWarsGameArea.zamorakPlayers.clear()
CastleWarsGameArea.loggedOutPlayers.clear()
CastleWarsWaitingArea.waitingSaradominPlayers.clear()
CastleWarsWaitingArea.waitingZamorakPlayers.clear()
CastleWarsGameArea.ticksLeftInGame = 0
}
}

View file

@ -0,0 +1,200 @@
package content.minigame.castlewars
import content.data.skill.SkillingTool
import core.api.*
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
import core.game.node.scenery.Scenery
import core.game.system.task.Pulse
import core.game.world.update.flag.context.Animation
/**
* Mining pulse for Castle Wars tunnel rocks.
*
* Uses coal ore difficulty parameters:
* - Level requirement: None (authentic Castle Wars behavior)
* - Rate: 0.40 (coal ore rate - difficulty factor for success chance)
*
* This pulse replaces the instant 3-tick delay with proper mining mechanics
* where players must successfully "hit" the rock multiple times to clear it.
*/
class CastleWarsTunnelMiningPulse( // TODO: tunnels in general are working great. but this... needs a thorough check from start to finish
private val player: Player,
private val rocks: Scenery,
private val boulderKey: String,
private val isWeakened: Boolean
) : Pulse(1, player, rocks) {
companion object {
private const val REQUIRED_LEVEL = 1
private const val RATE = 0.40
private const val MINING_ANIMATION = 625
private const val EXPERIENCE = 0.0
}
private var ticks = 0
private var started = false
override fun start() {
if (!checkRequirements()) {
return
}
super.start()
started = true
sendMessage(player, "You swing your pickaxe at the rocks...")
}
override fun pulse(): Boolean {
if (!started) return true
if (!checkRequirements()) {
return true
}
animate()
return checkReward()
}
override fun stop() {
if (started) {
animate(player, Animation(-1))
}
super.stop()
}
private fun checkRequirements(): Boolean {
val pickaxe = SkillingTool.getPickaxe(player)
if (pickaxe == null) {
sendMessage(player, "You need a pickaxe to mine these rocks.")
return false
}
if (!rocks.isActive) {
return false
}
return true
}
private fun animate() {
val pickaxe = SkillingTool.getPickaxe(player) ?: return
animate(player, pickaxe.animation)
}
private fun checkReward(): Boolean {
ticks++
if (ticks % 4 != 0) {
return false
}
val pickaxe = SkillingTool.getPickaxe(player) ?: return true
val level = 1 + getDynLevel(player, Skills.MINING) + getFamiliarBoost(player, Skills.MINING)
val hostRatio = Math.random() * (100.0 * RATE)
val toolRatio = pickaxe.ratio
val clientRatio = Math.random() * ((level - REQUIRED_LEVEL) * (1.0 + toolRatio))
if (hostRatio < clientRatio) {
clearRocks()
return true
}
return false
}
private fun clearRocks() {
rewardXP(player, Skills.MINING, EXPERIENCE)
if (isWeakened) {
CastleWarsGameState.clearBoulder(boulderKey)
sendMessage(player, "You manage to clear the rocks.")
} else {
CastleWarsGameState.weakenBoulder(boulderKey)
sendMessage(player, "You manage to weaken the rocks.")
}
}
}
class CastleWarsCaveWallMiningPulse(
private val player: Player, private val caveWall: Scenery, private val boulderKey: String
) : Pulse(1, player, caveWall) {
companion object {
private const val REQUIRED_LEVEL = 1
private const val RATE = 0.40
private const val EXPERIENCE = 0.0
}
private var ticks = 0
private var started = false
override fun start() {
if (!checkRequirements()) {
return
}
super.start()
started = true
sendMessage(player, "You swing your pickaxe at the cave wall...")
}
override fun pulse(): Boolean {
if (!started) return true
if (!checkRequirements()) {
return true
}
if (!CastleWarsGameState.isBoulderCleared(boulderKey)) {
sendMessage(player, "The passage has already collapsed.")
return true
}
animate()
return checkReward()
}
override fun stop() {
if (started) {
animate(player, Animation(-1))
}
super.stop()
}
private fun checkRequirements(): Boolean {
val pickaxe = SkillingTool.getPickaxe(player)
if (pickaxe == null) {
sendMessage(player, "You need a pickaxe to mine the cave wall.")
return false
}
return true
}
private fun animate() {
val pickaxe = SkillingTool.getPickaxe(player) ?: return
animate(player, pickaxe.animation)
}
private fun checkReward(): Boolean {
ticks++
if (ticks % 4 != 0) {
return false
}
val pickaxe = SkillingTool.getPickaxe(player) ?: return true
val level = 1 + getDynLevel(player, Skills.MINING) + getFamiliarBoost(player, Skills.MINING)
val hostRatio = Math.random() * (100.0 * RATE)
val toolRatio = pickaxe.ratio
val clientRatio = Math.random() * ((level - REQUIRED_LEVEL) * (1.0 + toolRatio))
if (hostRatio < clientRatio) {
collapsePassage()
return true
}
return false
}
private fun collapsePassage() {
rewardXP(player, Skills.MINING, EXPERIENCE)
CastleWarsGameState.collapseBoulder(boulderKey)
sendMessage(player, "You collapse the tunnel!")
}
}

View file

@ -1,25 +1,18 @@
package content.minigame.castlewars
import content.data.Quests
import content.global.handlers.iface.BookInterface
import content.global.handlers.iface.BookLine
import content.global.handlers.iface.Page
import content.global.handlers.iface.PageSet
import core.api.getAttribute
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
import core.game.node.entity.player.Player
import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_MODEL_NUMBER
import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_PITCH
import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_YAW
import core.game.system.command.sets.ModelViewerCommandSet.Companion.ATTRIBUTE_ZOOM
import core.game.system.command.sets.ModelViewerCommandSet.Companion.DEF_BOOK
import core.game.world.GameWorld
import org.rs09.consts.Items
class CastlewarsBook : InteractionListener {
companion object {
private val TITLE = "Castle Wars Manual"
private val CONTENTS = arrayOf(
PageSet(
@ -30,8 +23,7 @@ class CastlewarsBook : InteractionListener {
BookLine("their team standard. Then", 41),
BookLine("bring that back and capture", 42),
BookLine("it on your teams standard. ", 43),
),
Page(
), Page(
BookLine("Toolkit:", 58),
BookLine("This useful item allows you", 59),
BookLine("to repair broken doors and", 60),
@ -50,8 +42,7 @@ class CastlewarsBook : InteractionListener {
BookLine("of your running energy.", 46),
BookLine("You can also use them to", 47),
BookLine("heal fellow players.", 48),
),
Page(
), Page(
BookLine("Explosive Potion:", 58),
BookLine("A simple but effective item,", 59),
BookLine("use it to blow up your", 60),
@ -72,8 +63,7 @@ class CastlewarsBook : InteractionListener {
BookLine("your castle. Each team can", 47),
BookLine("only have 10 built at any one", 48),
BookLine("time.", 49),
),
Page(
), Page(
BookLine("Bucket:", 58),
BookLine("Fill a bucket with water and", 59),
BookLine("you can use it to put out a", 60),
@ -90,8 +80,7 @@ class CastlewarsBook : InteractionListener {
BookLine("set light to your opponents", 46),
BookLine("You can also use them to", 47),
BookLine("catapult and barricades.", 48),
),
Page(
), Page(
BookLine("Pickaxe:", 58),
BookLine("Use a pickaxe to mine your", 59),
BookLine("way through the tunnels", 60),
@ -111,8 +100,7 @@ class CastlewarsBook : InteractionListener {
BookLine("coordinates and let the rock", 47),
BookLine("fly, just be careful not to hit", 48),
BookLine("your team with it!", 49),
),
Page(
), Page(
BookLine("Rock:", 58),
BookLine("Used as ammo for the", 59),
BookLine("catapult, and not much else.", 60),
@ -121,18 +109,109 @@ class CastlewarsBook : InteractionListener {
)
),
)
private fun display(player: Player, pageNum: Int, buttonID: Int) : Boolean {
private fun display(player: Player, pageNum: Int, buttonID: Int): Boolean {
BookInterface.pageSetup(player, BookInterface.FANCY_BOOK_2_27, TITLE, CONTENTS)
BookInterface.clearModelsOnPage(player, BookInterface.FANCY_BOOK_2_27);
BookInterface.setItemOnPage(player, 0, Items.TOOLKIT_4051, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792)
BookInterface.setItemOnPage(player, 1, Items.BANDAGES_4049, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792)
BookInterface.setItemOnPage(player, 1, Items.EXPLOSIVE_POTION_4045, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792)
BookInterface.setItemOnPage(player, 2, Items.BARRICADE_4053, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792)
BookInterface.setItemOnPage(player, 2, Items.BUCKET_OF_WATER_1929, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792)
BookInterface.setItemOnPage(player, 3, Items.TINDERBOX_590, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2], 768, 192, 1792)
BookInterface.setItemOnPage(player, 3, Items.BRONZE_PICKAXE_1265, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792)
BookInterface.setModelOnPage(player, 4, 38202, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[4], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[4], 4048, 192, 768)
BookInterface.setItemOnPage(player, 4, Items.ROCK_4043, BookInterface.FANCY_BOOK_2_27, BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17], BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17], 768, 192, 1792)
BookInterface.clearModelsOnPage(player, BookInterface.FANCY_BOOK_2_27)
BookInterface.setItemOnPage(
player,
0,
Items.TOOLKIT_4051,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
1,
Items.BANDAGES_4049,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
1,
Items.EXPLOSIVE_POTION_4045,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
2,
Items.BARRICADE_4053,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
2,
Items.BUCKET_OF_WATER_1929,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
3,
Items.TINDERBOX_590,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[2],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[2],
768,
192,
1792
)
BookInterface.setItemOnPage(
player,
3,
Items.BRONZE_PICKAXE_1265,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17],
768,
192,
1792
)
BookInterface.setModelOnPage(
player,
4,
38202,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[4],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[4],
4048,
192,
768
)
BookInterface.setItemOnPage(
player,
4,
Items.ROCK_4043,
BookInterface.FANCY_BOOK_2_27,
BookInterface.FANCY_BOOK_2_27_IMAGE_ENABLE_DRAW_IDS[17],
BookInterface.FANCY_BOOK_2_27_IMAGE_DRAW_IDS[17],
768,
192,
1792
)
return true
}
}

View file

@ -0,0 +1,126 @@
package content.minigame.castlewars
import core.game.world.map.Location
import org.rs09.consts.Components
/**
* Configuration constants for the Castle Wars catapult interface.
* All magic numbers and hardcoded values are centralized here.
*/
object CatapultConfig { // TODO: this is definitely fucked somehow
/** Interface ID for the catapult targeting interface */
const val INTERFACE_ID = Components.CASTLEWARS_CATAPULT_54
/** Child ID for the red X marker on the map */
const val MARKER_CHILD_ID = 55
/** Enum ID for digit models (maps 0-9 to model IDs) */
const val DIGIT_MODEL_ENUM = 1595
const val BUTTON_FIRE = 51
const val BUTTON_CLOSE = 56
const val BUTTON_UP = 65
const val BUTTON_DOWN = 66
const val BUTTON_RIGHT = 67
const val BUTTON_LEFT = 68
val DIGIT_CHILD_IDS = intArrayOf(69, 70, 71, 72)
const val MAP_BASE_X = 12
const val MAP_BASE_Y = 20
const val MAP_WIDTH = 485
const val MAP_HEIGHT = 297
const val MAX_COORDINATE = CastleWarsConstants.CATAPULT_MAX_COORDINATE
const val DEFAULT_COORDINATE = CastleWarsConstants.CATAPULT_DEFAULT_COORDINATE
const val CENTER_OFFSET = 15
val SARADOMIN_BASE = Location.create(2426, 3073, 0)
val ZAMORAK_BASE = Location.create(2373, 3134, 0)
const val SOUND_FIRE = 1440
const val SOUND_IMPACT = 851
const val GFX_BOULDER_PROJECTILE = 857
const val GFX_IMPACT = 406
const val PROJECTILE_START_HEIGHT = 80
const val PROJECTILE_END_HEIGHT = 0
const val PROJECTILE_DELAY = 0
const val PROJECTILE_BASE_SPEED = 60
const val PROJECTILE_SPEED_PER_TILE = 5
const val PROJECTILE_ANGLE = 25
const val IMPACT_RADIUS = CastleWarsConstants.CATAPULT_IMPACT_RADIUS
const val MIN_DAMAGE = CastleWarsConstants.CATAPULT_MIN_DAMAGE
const val MAX_DAMAGE = CastleWarsConstants.CATAPULT_MAX_DAMAGE
const val INACCURACY_RADIUS = CastleWarsConstants.CATAPULT_INACCURACY_RADIUS
const val ATTR_LOCATION = "catapult:location"
}
/**
* Enum representing the buttons on the catapult interface.
*/
enum class CatapultButton(val buttonId: Int, val deltaX: Int, val deltaY: Int) {
LEFT(CatapultConfig.BUTTON_LEFT, -1, 0),
RIGHT(CatapultConfig.BUTTON_RIGHT, 1, 0),
UP(CatapultConfig.BUTTON_UP, 0, 1),
DOWN(CatapultConfig.BUTTON_DOWN, 0, -1),
FIRE(CatapultConfig.BUTTON_FIRE, 0, 0),
CLOSE(CatapultConfig.BUTTON_CLOSE, 0, 0),
UNKNOWN(-1, 0, 0);
companion object {
fun fromId(id: Int): CatapultButton = values().find { it.buttonId == id } ?: UNKNOWN
}
}
/**
* Enum representing team-specific catapult configurations.
* Handles coordinate transformations based on team orientation.
*/
enum class CatapultTeam(val baseLocation: Location) {
SARADOMIN(CatapultConfig.SARADOMIN_BASE) {
override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int {
return catapultX + (interfaceX - CatapultConfig.CENTER_OFFSET)
}
override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int {
return catapultY + interfaceY
}
},
ZAMORAK(CatapultConfig.ZAMORAK_BASE) {
override fun calculateTargetX(catapultX: Int, interfaceX: Int): Int {
return catapultX - (interfaceX - CatapultConfig.CENTER_OFFSET)
}
override fun calculateTargetY(catapultY: Int, interfaceY: Int): Int {
return catapultY - interfaceY
}
};
abstract fun calculateTargetX(catapultX: Int, interfaceX: Int): Int
abstract fun calculateTargetY(catapultY: Int, interfaceY: Int): Int
companion object {
fun fromCastleWarsTeam(team: CastleWarsTeam): CatapultTeam = when (team) {
CastleWarsTeam.SARADOMIN -> SARADOMIN
CastleWarsTeam.ZAMORAK -> ZAMORAK
}
}
}
/**
* Data class representing X/Y coordinates on the catapult interface.
*/
data class Coordinate(val x: Int, val y: Int) {
fun clamp(): Coordinate = Coordinate(
x.coerceIn(0, CatapultConfig.MAX_COORDINATE),
y.coerceIn(0, CatapultConfig.MAX_COORDINATE)
)
fun withDelta(deltaX: Int, deltaY: Int): Coordinate = Coordinate(x + deltaX, y + deltaY)
}

View file

@ -0,0 +1,116 @@
package content.minigame.castlewars
import content.minigame.castlewars.CatapultConfig.GFX_BOULDER_PROJECTILE
import content.minigame.castlewars.CatapultConfig.GFX_IMPACT
import content.minigame.castlewars.CatapultConfig.IMPACT_RADIUS
import content.minigame.castlewars.CatapultConfig.INACCURACY_RADIUS
import content.minigame.castlewars.CatapultConfig.MAX_DAMAGE
import content.minigame.castlewars.CatapultConfig.MIN_DAMAGE
import content.minigame.castlewars.CatapultConfig.PROJECTILE_ANGLE
import content.minigame.castlewars.CatapultConfig.PROJECTILE_BASE_SPEED
import content.minigame.castlewars.CatapultConfig.PROJECTILE_DELAY
import content.minigame.castlewars.CatapultConfig.PROJECTILE_END_HEIGHT
import content.minigame.castlewars.CatapultConfig.PROJECTILE_SPEED_PER_TILE
import content.minigame.castlewars.CatapultConfig.PROJECTILE_START_HEIGHT
import content.minigame.castlewars.CatapultConfig.SOUND_FIRE
import content.minigame.castlewars.CatapultConfig.SOUND_IMPACT
import core.api.*
import core.game.node.entity.combat.ImpactHandler
import core.game.node.entity.impl.Projectile
import core.game.node.entity.player.Player
import core.game.system.task.Pulse
import core.game.world.map.Location
import core.game.world.map.RegionManager
import core.tools.RandomFunction
/**
* Handles firing the catapult and dealing impact damage.
*/
class CatapultFiringHandler { // TODO: i dont think this even works.. at all even
/**
* Fires the catapult at the specified coordinates.
* @param player The player firing the catapult
* @param coord The interface coordinates to fire at
* @param team The catapult team configuration
* @param catapultLocation The actual world location of the catapult
*/
fun fire(player: Player, coord: Coordinate, team: CatapultTeam, catapultLocation: Location) {
val targetLocation = calculateTargetLocation(team, catapultLocation, coord)
val finalLocation = applyInaccuracy(targetLocation)
val distance = catapultLocation.getDistance(finalLocation).toInt()
val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE)
val impactDelayTicks = (speed / 30).coerceAtLeast(1)
sendProjectile(catapultLocation, finalLocation)
playFireEffects(player, finalLocation, impactDelayTicks)
scheduleImpact(player, finalLocation, impactDelayTicks)
sendMessage(player, "You fire the catapult!")
}
private fun calculateTargetLocation(team: CatapultTeam, catapultLocation: Location, coord: Coordinate): Location {
val targetX = team.calculateTargetX(catapultLocation.x, coord.x)
val targetY = team.calculateTargetY(catapultLocation.y, coord.y)
return Location.create(targetX, targetY, 0)
}
private fun applyInaccuracy(target: Location): Location {
val finalX = target.x + RandomFunction.random(-INACCURACY_RADIUS, INACCURACY_RADIUS)
val finalY = target.y + RandomFunction.random(-INACCURACY_RADIUS, INACCURACY_RADIUS)
return Location.create(finalX, finalY, 0)
}
private fun sendProjectile(from: Location, to: Location) {
val distance = from.getDistance(to).toInt()
val speed = PROJECTILE_BASE_SPEED + (distance * PROJECTILE_SPEED_PER_TILE)
Projectile.create(
from,
to,
GFX_BOULDER_PROJECTILE,
PROJECTILE_START_HEIGHT,
PROJECTILE_END_HEIGHT,
PROJECTILE_DELAY,
speed,
PROJECTILE_ANGLE,
distance
).send()
}
private fun playFireEffects(player: Player, targetLocation: Location, impactDelayTicks: Int) {
playAudio(player, SOUND_FIRE)
submitWorldPulse(object : Pulse(impactDelayTicks) {
override fun pulse(): Boolean {
sendGraphics(GFX_IMPACT, targetLocation)
playGlobalAudio(targetLocation, SOUND_IMPACT)
return true
}
})
}
private fun scheduleImpact(player: Player, targetLocation: Location, impactDelayTicks: Int) {
submitWorldPulse(object : Pulse(impactDelayTicks) {
override fun pulse(): Boolean {
dealImpactDamage(player, targetLocation)
return true
}
})
}
private fun dealImpactDamage(attacker: Player, targetLocation: Location) {
RegionManager.getLocalPlayers(targetLocation, IMPACT_RADIUS)
.filter { isValidTarget(it, attacker) }
.forEach { target ->
val damage = RandomFunction.random(MIN_DAMAGE, MAX_DAMAGE)
target.impactHandler.manualHit(attacker, damage, ImpactHandler.HitsplatType.NORMAL)
sendMessage(target, "You are hit by a catapult rock!")
}
}
private fun isValidTarget(target: Player, attacker: Player): Boolean {
return CastleWarsGameState.getPlayerTeam(target) != null
}
}

View file

@ -0,0 +1,97 @@
package content.minigame.castlewars
import content.minigame.castlewars.CatapultConfig.DEFAULT_COORDINATE
import content.minigame.castlewars.CatapultConfig.DIGIT_CHILD_IDS
import content.minigame.castlewars.CatapultConfig.DIGIT_MODEL_ENUM
import content.minigame.castlewars.CatapultConfig.INTERFACE_ID
import content.minigame.castlewars.CatapultConfig.MAP_BASE_X
import content.minigame.castlewars.CatapultConfig.MAP_BASE_Y
import content.minigame.castlewars.CatapultConfig.MAP_HEIGHT
import content.minigame.castlewars.CatapultConfig.MAP_WIDTH
import content.minigame.castlewars.CatapultConfig.MARKER_CHILD_ID
import content.minigame.castlewars.CatapultConfig.MAX_COORDINATE
import core.cache.def.impl.DataMap
import core.game.node.entity.player.Player
import core.net.packet.PacketRepository
import core.net.packet.context.ChildPositionContext
import core.net.packet.context.DisplayModelContext
import core.net.packet.out.DisplayModel
import core.net.packet.out.RepositionChild
/**
* Handles rendering the catapult interface including marker position and digit displays.
*/
class CatapultInterfaceRenderer { // TODO: this is getting very close to 100%. still fucked but on the right track for sure
/**
* Updates the position of the red X marker on the interface map.
* @param player The player whose interface to update
* @param coord The current X/Y coordinates
*/
fun updateMarkerPosition(player: Player, coord: Coordinate) {
val screenX = calculateScreenX(coord.x)
val screenY = calculateScreenY(coord.y)
PacketRepository.send(
RepositionChild::class.java,
ChildPositionContext(player, INTERFACE_ID, MARKER_CHILD_ID, screenX, screenY)
)
}
/**
* Updates the digit displays showing the current X and Y coordinates.
* @param player The player whose interface to update
* @param coord The current X/Y coordinates
*/
fun updateDigitDisplay(player: Player, coord: Coordinate) {
val xTens = coord.x / 10
val xOnes = coord.x % 10
val yTens = coord.y / 10
val yOnes = coord.y % 10
sendDigitModel(player, DIGIT_CHILD_IDS[0], xTens)
sendDigitModel(player, DIGIT_CHILD_IDS[1], xOnes)
sendDigitModel(player, DIGIT_CHILD_IDS[2], yTens)
sendDigitModel(player, DIGIT_CHILD_IDS[3], yOnes)
}
/**
* Resets the interface to default coordinates.
* @param player The player whose interface to reset
*/
fun resetToDefaults(player: Player) {
val defaultCoord = Coordinate(DEFAULT_COORDINATE, DEFAULT_COORDINATE)
updateMarkerPosition(player, defaultCoord)
updateDigitDisplay(player, defaultCoord)
}
private fun calculateScreenX(coordX: Int): Int {
val pixelsPerUnit = MAP_WIDTH / MAX_COORDINATE
return MAP_BASE_X + (coordX * pixelsPerUnit)
}
private fun calculateScreenY(coordY: Int): Int {
val pixelsPerUnit = MAP_HEIGHT / MAX_COORDINATE
// Y is inverted - 0 is at the bottom, MAX is at the top
return MAP_BASE_Y + ((MAX_COORDINATE - coordY) * pixelsPerUnit)
}
private fun sendDigitModel(player: Player, childId: Int, digit: Int) {
val modelId = getDigitModelId(digit)
PacketRepository.send(
DisplayModel::class.java,
DisplayModelContext(
player,
DisplayModelContext.ModelType.MODEL,
modelId,
0,
INTERFACE_ID,
childId
)
)
}
private fun getDigitModelId(digit: Int): Int {
return DataMap.get(DIGIT_MODEL_ENUM).getInt(digit)
}
}

View file

@ -1,15 +1,19 @@
package rs09.game.content.activity.castlewars
package content.minigame.castlewars
import content.minigame.castlewars.areas.CastleWarsGameArea
import content.minigame.castlewars.areas.CastleWarsWaitingArea
import core.api.TickListener
import core.api.findNPC
import core.api.openDialogue
import core.api.sendChat
import core.api.sendMessage
import core.game.dialogue.DialogueFile
import core.game.dialogue.FacialExpression
import core.game.interaction.IntType
import core.game.interaction.InteractionListener
import core.game.node.entity.npc.NPC
import core.game.node.item.Item
import core.tools.END_DIALOGUE
import core.tools.ticksPerMinute
import org.rs09.consts.Items
import org.rs09.consts.NPCs
@ -96,7 +100,7 @@ class Lanthus: InteractionListener, TickListener {
90 -> npcl(
FacialExpression.FRIENDLY,
"Players on the winning team will receive 2 Castle Wars Tickets which you can trade back to me for other items. In the event of a draw every player will get 1 ticket."
"Players on the winning team will receive 2 Castle Wars Tickets which you can trade back to me for other items. In the event of a draw every player will get 1 ticket. Losers receive no tickets."
).also { stage ++ }
91 -> options(
"Are there any rules?",
@ -122,10 +126,30 @@ class Lanthus: InteractionListener, TickListener {
}
}
override fun tick() {
"The next game will start in 4 minutes"
"The next game will start in 1 minute"
// He also talks to Postie Pete sometimes?
// Track the last announced minute to avoid spamming the same announcement every tick
private var lastAnnouncedMinute = -1
override fun tick() {
// Only announce when no game is active and a countdown is running
if (CastleWarsGameArea.ticksLeftInGame > 0) {
lastAnnouncedMinute = -1 // Reset for next countdown cycle
return
}
val countdown = CastleWarsWaitingArea.gameStartCountdown
if (countdown < 0) {
lastAnnouncedMinute = -1 // No countdown active
return
}
val minutesLeft = (countdown + ticksPerMinute - 1) / ticksPerMinute
// Announce at minute boundaries, only once per distinct minute value
if (minutesLeft != lastAnnouncedMinute && minutesLeft in 1..5) {
lastAnnouncedMinute = minutesLeft
val lanthus = findNPC(NPCs.LANTHUS_1526) ?: return
val minuteWord = if (minutesLeft == 1) "minute" else "minutes"
sendChat(lanthus, "The next game will start in $minutesLeft $minuteWord!")
}
}
}