diff --git a/data/config/items/quests/romeo-and-juliet.json b/data/config/items/quests/romeo-and-juliet.json new file mode 100644 index 000000000..dbcf60f4d --- /dev/null +++ b/data/config/items/quests/romeo-and-juliet.json @@ -0,0 +1,8 @@ +{ + "rs:juliet_letter": { + "game_id": 755, + "tradable": false, + "examine": "A message from Juliet to Romeo.", + "weight": 0.02 + } +} diff --git a/data/config/npc-spawns/varrock/varrock-general.json b/data/config/npc-spawns/varrock/varrock-general.json index 3b53657a9..8479c7c71 100644 --- a/data/config/npc-spawns/varrock/varrock-general.json +++ b/data/config/npc-spawns/varrock/varrock-general.json @@ -35,6 +35,58 @@ "spawn_y": 3424, "movement_radius": 4 }, + { + "npc": "rs:draul_leptoc", + "spawn_x": 3162, + "spawn_y": 3435, + "movement_radius": 4 + }, + { + "npc": "rs:father_lawrence", + "spawn_x": 3254, + "spawn_y": 3485, + "movement_radius": 2 + }, + { + "npc": "rs:martina_scorsby", + "spawn_x": 3256, + "spawn_y": 3481, + "movement_radius": 0, + "face": "NORTH" + }, + { + "npc": "rs:jeremy_clerksin", + "spawn_x": 3253, + "spawn_y": 3477, + "movement_radius": 0, + "face": "NORTH" + }, + { + "npc": "rs:apothecary", + "spawn_x": 3196, + "spawn_y": 3404, + "movement_radius": 3 + }, + { + "npc": "rs:man", + "spawn_x": 3153, + "spawn_y": 3429, + "movement_radius": 4 + }, + { + "npc": "rs:phillipa", + "spawn_x": 3163, + "spawn_y": 3430, + "spawn_level": 1, + "movement_radius": 5 + }, + { + "npc": "rs:juliet", + "spawn_x": 3158, + "spawn_y": 3425, + "spawn_level": 1, + "movement_radius": 1 + }, { "npc": "rs:varrock_shop_keeper", "spawn_x": 3214, diff --git a/data/config/npcs/varrock.json b/data/config/npcs/varrock.json index d38839768..a2ca4fcde 100644 --- a/data/config/npcs/varrock.json +++ b/data/config/npcs/varrock.json @@ -29,6 +29,33 @@ "rs:romeo": { "game_id": 639 }, + "rs:draul_leptoc": { + "game_id": 3324 + }, + "rs:phillipa": { + "game_id": 3325 + }, + "rs:juliet": { + "game_id": 3323, + "variations": [ + { + "suffix": "visible", + "game_id": 637 + } + ] + }, + "rs:apothecary": { + "game_id": 638 + }, + "rs:father_lawrence": { + "game_id": 640 + }, + "rs:martina_scorsby": { + "game_id": 3326 + }, + "rs:jeremy_clerksin": { + "game_id": 3327 + }, "rs:varrock_shop_keeper": { "game_id": 520 }, diff --git a/data/config/widgets.json5 b/data/config/widgets.json5 index 6f4691631..486242ac3 100644 --- a/data/config/widgets.json5 +++ b/data/config/widgets.json5 @@ -87,5 +87,6 @@ christmas: 23, killcount: 24 }, - whatWouldYouLikeToSpin: 459 + whatWouldYouLikeToSpin: 459, + fade: 115 } diff --git a/src/game-engine/config/index.ts b/src/game-engine/config/index.ts index 0e52d9bbe..d5dea5941 100644 --- a/src/game-engine/config/index.ts +++ b/src/game-engine/config/index.ts @@ -40,7 +40,7 @@ export let skillGuides: SkillGuide[] = []; export let xteaRegions: { [key: number]: XteaRegion }; export const musicRegionMap = new Map(); -export const widgets: { [key: string]: any } = require('../../../data/config/widgets.json5'); +export const widgets = require('../../../data/config/widgets.json5'); export async function loadCoreConfigurations(): Promise { xteaRegions = await loadXteaRegionFiles('data/config/xteas'); diff --git a/src/game-engine/config/minimap-state.ts b/src/game-engine/config/minimap-state.ts new file mode 100644 index 000000000..fbfd385b7 --- /dev/null +++ b/src/game-engine/config/minimap-state.ts @@ -0,0 +1,4 @@ +export enum MinimapState { + NORMAL = 0, + BLACK = 2 +} diff --git a/src/game-engine/config/quest-config.ts b/src/game-engine/config/quest-config.ts index 5906a5d85..b5aeadbf4 100644 --- a/src/game-engine/config/quest-config.ts +++ b/src/game-engine/config/quest-config.ts @@ -2,7 +2,6 @@ import { Player } from '@engine/world/actor/player/player'; import { Npc } from '@engine/world/actor/npc/npc'; import { npcInteractionActionHandler } from '@engine/world/action/npc-interaction.action'; import { logger } from '@runejs/core'; -import { handleTutorial } from '@plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy-quest.plugin'; export type QuestKey = number | 'complete'; @@ -12,7 +11,7 @@ export type QuestStageHandler = { }; export type QuestDialogueHandler = { - [key in QuestKey]?: (player: Player, npc: Npc) => void | Promise; + [key in QuestKey]?: ((player: Player, npc: Npc) => void | Promise) | number; }; export type QuestJournalHandler = { @@ -45,20 +44,19 @@ export class PlayerQuest { export function questDialogueActionFactory(questId: string, npcDialogueHandler: QuestDialogueHandler): npcInteractionActionHandler { return async({ player, npc }) => { const quest = player.getQuest(questId); - if(!quest) { - return; + const progress = quest.progress; + + let dialogueHandler = npcDialogueHandler[progress]; + if (dialogueHandler != null && typeof dialogueHandler === 'number') { + dialogueHandler = npcDialogueHandler[dialogueHandler] } - const progress = quest.progress; - const dialogueHandler = npcDialogueHandler[progress]; - if(dialogueHandler) { + if (dialogueHandler != null && typeof dialogueHandler === 'function') { try { await dialogueHandler(player, npc); } catch(e) { logger.error(e); } - - await handleTutorial(player); } }; } diff --git a/src/game-engine/net/inbound-packets/walk-packet.js b/src/game-engine/net/inbound-packets/walk-packet.js index ffba42094..0b293e519 100644 --- a/src/game-engine/net/inbound-packets/walk-packet.js +++ b/src/game-engine/net/inbound-packets/walk-packet.js @@ -1,6 +1,11 @@ const walkPacket = (player, packet) => { const { buffer, packetSize, packetId } = packet; + // Don't add to the walking queue if busy. If this poses problems, feel free to move it somewhere else. + if (player.busy) { + return; + } + let size = packetSize; if(packetId === 236) { size -= 14; diff --git a/src/game-engine/net/outbound-packets.ts b/src/game-engine/net/outbound-packets.ts index 670d14e3e..0bc2be34a 100644 --- a/src/game-engine/net/outbound-packets.ts +++ b/src/game-engine/net/outbound-packets.ts @@ -11,6 +11,7 @@ import { Npc } from '@engine/world/actor/npc/npc'; import { stringToLong } from '@engine/util/strings'; import { LandscapeObject } from '@runejs/filestore'; import { xteaRegions } from '@engine/config'; +import { MinimapState } from '@engine/config/minimap-state'; import { world } from '@engine/game-server'; import { ConstructedChunk, ConstructedRegion } from '@engine/world/map/region'; @@ -51,6 +52,12 @@ export class OutboundPackets { this.queue(packet); } + public setMinimapState(minimapState: MinimapState): void { + const packet = new Packet(235); + packet.put(minimapState); + this.queue(packet); + } + public updateSocialSettings(): void { const packet = new Packet(196); packet.put(this.player.settings.publicChatMode || 0); @@ -232,16 +239,6 @@ export class OutboundPackets { this.queue(packet); } - // Text dialogs = 356, 359, 363, 368, 374 - // Item dialogs = 519 - // Statements (no click to continue) = 210, 211, 212, 213, 214 - public showChatboxWidget(widgetId: number): void { - const packet = new Packet(208); - packet.put(widgetId, 'SHORT'); - - this.queue(packet); - } - public setWidgetNpcHead(widgetId: number, childId: number, modelId: number): void { const packet = new Packet(160); packet.put(modelId, 'SHORT', 'LITTLE_ENDIAN'); @@ -298,11 +295,11 @@ export class OutboundPackets { this.queue(packet); } - public setWidgetModelRotationAndZoom(widgetId: number, childId: number, rotationX: number, rotationY: number, zoom: number): void { + public setWidgetModelRotationAndZoom(widgetId: number, childId: number, rotationY: number, rotationX: number, zoom: number): void { const packet = new Packet(142); - packet.put(rotationX, 'SHORT'); - packet.put(zoom, 'SHORT', 'LITTLE_ENDIAN'); packet.put(rotationY, 'SHORT'); + packet.put(zoom, 'SHORT', 'LITTLE_ENDIAN'); + packet.put(rotationX, 'SHORT'); packet.put(widgetId << 16 | childId, 'INT', 'LITTLE_ENDIAN'); this.queue(packet); @@ -487,7 +484,24 @@ export class OutboundPackets { this.queue(packet); } - public showChatDialogue(widgetId: number): void { + /** + * Show or replace dialogue in the chatbox slot, resetting all the other slots client-side + * This widget will close when walking + * @param widgetId The widget ID + */ + public showChatboxWidget(widgetId: number): void { + const packet = new Packet(208); + packet.put(widgetId, 'SHORT'); + + this.queue(packet); + } + + /** + * Show permanent widget in the dialogue slot that preserves all widgets, even previously opened chatbox widgets + * This widget will NOT close when walking + * @param widgetId The widget ID + */ + public showPermanentDialogueWidget(widgetId: number): void { const packet = new Packet(185); packet.put(widgetId, 'SHORT'); this.queue(packet); diff --git a/src/game-engine/util/strings.ts b/src/game-engine/util/strings.ts index c00c17a10..291d8a36f 100644 --- a/src/game-engine/util/strings.ts +++ b/src/game-engine/util/strings.ts @@ -1,4 +1,6 @@ import { hexToHexString } from '@engine/util/colors'; +import { filestore } from '@engine/game-server'; +import { Font, FontName } from '@runejs/filestore'; export const startsWithVowel = (str: string): boolean => { str = str.trim().toLowerCase(); @@ -8,64 +10,145 @@ export const startsWithVowel = (str: string): boolean => { return (firstChar === 'a' || firstChar === 'e' || firstChar === 'i' || firstChar === 'o' || firstChar === 'u'); }; -// Thank you to the Apollo team for these values. :) -const charWidths = [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 7, 14, 9, 12, 12, 4, 5, - 5, 10, 8, 4, 8, 4, 7, 9, 7, 9, 8, 8, 8, 9, 7, 9, 9, 4, 5, 7, - 9, 7, 9, 14, 9, 8, 8, 8, 7, 7, 9, 8, 6, 8, 8, 7, 10, 9, 9, 8, - 9, 8, 8, 6, 9, 8, 10, 8, 8, 8, 6, 7, 6, 9, 10, 5, 8, 8, 7, 8, - 8, 7, 8, 8, 4, 7, 7, 4, 10, 8, 8, 8, 8, 6, 8, 6, 8, 8, 9, 8, - 8, 8, 6, 4, 6, 12, 3, 10, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 4, 8, 11, 8, 8, 4, 8, 7, 12, 6, 7, 9, 5, 12, 5, 6, 10, 6, 6, 6, - 8, 8, 4, 5, 5, 6, 7, 11, 11, 11, 9, 9, 9, 9, 9, 9, 9, 13, 8, 8, - 8, 8, 8, 4, 4, 5, 4, 8, 9, 9, 9, 9, 9, 9, 8, 10, 9, 9, 9, 9, - 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 6, 8, 8, 8, 8, 4, 4, 5, 4, 8, - 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]; - -export function wrapText(text: string, maxWidth: number): string[] { - const lines = []; +export enum TextDecoration { + Color, + Decoration +} - let lineStartIdx = 0; - let width = 0; - let lastSpace = 0; - let widthAfterSpace = 0; - let lastSpaceChar = ''; +function getStylingType(tag: string) { + let _tag = tag; + if (_tag.charAt(0) === '/') { + _tag = _tag.substring(1); + } - for (let i = 0; i < text.length; i++) { - const char = text.charAt(i); + if (_tag.startsWith('col')) { + return TextDecoration.Color; + } else { + return TextDecoration.Decoration; + } +} - // Ignore and strings... - if (char === '<' && (text.charAt(i + 1) === '/' || text.charAt(i + 1) === 'c' && text.charAt(i + 2) === 'o' && text.charAt(i + 3) === 'l')) { - const tagCloseIndex = text.indexOf('>', i); - i = tagCloseIndex; - continue; +// TODO refactor a bit +export function wrapText(text: string, maxWidth: number, font?: number | string): string[] { + const lines = []; + const selectedFont = getFont(font); + const colorQueue: string[] = []; + const decorationQueue: string[] = []; + const remainingText = text.split('').reverse(); + let currentLine = ''; + let currentWidth = 0; + let currentTagIndex = -1; + + while (remainingText.length > 0) { + const char = remainingText.pop(); + + let hidden = false; + let rendered = true; + + switch (char) { + case '<': + hidden = true; + currentTagIndex = currentLine.length + 1; + break; + case '>': + hidden = true; + // eslint-disable-next-line no-case-declarations + const currentTag = currentLine.substring(currentTagIndex, currentLine.length); + currentTagIndex = -1; + // eslint-disable-next-line no-case-declarations + const isClosing = currentTag.charAt(0) === '/'; + // eslint-disable-next-line no-case-declarations + const type = getStylingType(currentTag); + if (type === TextDecoration.Decoration) { + if (!isClosing) { + decorationQueue.push(currentTag); + } else { + decorationQueue.pop(); + } + } else { + if (!isClosing) { + colorQueue.push(currentTag); + } else { + colorQueue.pop(); + } + } + break; + case '@': + break; + case '\n': + hidden = true; + currentWidth = maxWidth; + rendered = false; + break; + case ' ': + if (currentLine[currentLine.length-1] === ' ' || currentWidth === 0){ + hidden = true; + rendered = false; + } + break; + default: + break; } - const charWidth = charWidths[text.charCodeAt(i)]; - width += charWidth; - widthAfterSpace += charWidth; + if (rendered) { + currentLine += char; + } - if (char === ' ' || char === '\n' || char === '-') { - lastSpaceChar = char; - lastSpace = i; - widthAfterSpace = 0; + if (!hidden && currentTagIndex == -1) { + const charWidth = selectedFont.getCharWidth(char); + currentWidth += charWidth; } - if (width >= maxWidth || char === '\n') { - lines.push(text.substring(lineStartIdx, lastSpaceChar === '-' ? lastSpace + 1 : lastSpace)); - lineStartIdx = lastSpace + 1; - width = widthAfterSpace; + if (currentWidth >= maxWidth) { + let lastSpace = currentLine.lastIndexOf(' '); + const lastTag = currentLine.lastIndexOf('<'); + if (lastTag > lastSpace && char !== '\n') { + lastSpace = lastTag; + const type = getStylingType(currentLine.substring(lastTag+1)); + if (type === TextDecoration.Decoration) { + decorationQueue.pop(); + } else { + colorQueue.pop(); + } + } + let lineToPush = currentLine; + let remainder = ''; + if (lastSpace != -1 && char != '\n') { + lineToPush = lineToPush.substring(0, lastSpace); + remainder = currentLine.substring(lastSpace); + + } + + decorationQueue.slice(0).reverse().map(tag => lineToPush += ``); + colorQueue.slice(0).reverse().map(tag => lineToPush += ``); + lines.push(lineToPush.trim()); + currentLine = ''; + decorationQueue.slice(0).map(tag => currentLine += `<${tag}>`); + colorQueue.slice(0).map(tag => currentLine += `<${tag}>`); + remainingText.push(...remainder.split('').reverse()) + currentWidth = 0; } + } - if (lineStartIdx !== text.length - 1) { - lines.push(text.substring(lineStartIdx, text.length)); + if (currentLine !== '') { + lines.push(currentLine); } return lines; } +function getFont(font: number | string) { + if (font && typeof font === 'number') { + return filestore.fontStore.getFontById(font); + } else if (font && typeof font === 'string') { + return filestore.fontStore.getFontByName(FontName[font]); + } else { + // Default font, subject to change + return filestore.fontStore.getFontByName(FontName.p12_full); + } +} + const VALID_CHARS = ['_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', diff --git a/src/game-engine/util/varbits.ts b/src/game-engine/util/varbits.ts index 9b955d351..bba859535 100644 --- a/src/game-engine/util/varbits.ts +++ b/src/game-engine/util/varbits.ts @@ -1,32 +1,45 @@ import { filestore } from '@engine/game-server'; -import { findNpc } from '@engine/config'; -import { logger } from '@runejs/core'; -import { Npc } from '@engine/world/actor/npc/npc'; -import { Player } from '@engine/world/actor/player/player'; - -const varbitMasks = []; /** - * Returns the index to morph actor/object into, based on set config - * @param varbitId - * @param playerConfig - * @return index to morph into + * Calculate the varbit masks + * @returns an array of varbit masks */ -export function getVarbitMorphIndex(varbitId, playerConfig) { - if(varbitMasks.length === 0) { +export function calculateVarbitMasks() { + const varbitMasks = []; + if (varbitMasks.length === 0) { let i = 2; for (let i_7_ = 0; i_7_ < 32; i_7_++) { varbitMasks[i_7_] = -1 + i; i += i; } } + return varbitMasks; +} + +/** + * Returns the index to morph actor/object into, based on set config + * @param varbitId + * @param playerConfig + * @return index to morph into + */ +export function getVarbitMorphIndex(varbitId, playerConfig) { + const varbitMasks = calculateVarbitMasks(); const varbitDefinition = filestore.configStore.varbitStore.getVarbit(varbitId); const mostSignificantBit = varbitDefinition.mostSignificantBit; const configId = varbitDefinition.index; const leastSignificantBit = varbitDefinition.leastSignificantBit; - // TODO: Unknown - const i_8_ = varbitMasks[mostSignificantBit - leastSignificantBit]; + const varbitMask = varbitMasks[mostSignificantBit - leastSignificantBit]; const configValue = playerConfig && playerConfig[configId] ? playerConfig[configId] : 0; - return ((configValue) >> leastSignificantBit & i_8_); + return ((configValue) >> leastSignificantBit & varbitMask); +} + +/** + * Returns the setting/config index from a varbitId + * @param varbitId + * @return the config ID for the varbit + */ +export function getVarbitConfigId(varbitId) { + const varbitDefinition = filestore.configStore.varbitStore.getVarbit(varbitId); + return varbitDefinition.index; } diff --git a/src/game-engine/world/action/hooks/hook-filters.ts b/src/game-engine/world/action/hooks/hook-filters.ts index 0a8bee8f9..28a6729a9 100644 --- a/src/game-engine/world/action/hooks/hook-filters.ts +++ b/src/game-engine/world/action/hooks/hook-filters.ts @@ -70,24 +70,20 @@ export function questHookFilter(player: Player, actionHook: ActionHook): boolean const questId = actionHook.questRequirement.questId; const playerQuest = player.quests.find(quest => quest.questId === questId); - if(!playerQuest) { + if (!playerQuest) { // @TODO quest requirements - return actionHook.questRequirement.stage === 0; + return actionHook.questRequirement.stage === 0 || actionHook.questRequirement.stages?.indexOf(0) !== -1; } - if(actionHook.questRequirement.stage === 'complete') { + if (actionHook.questRequirement.stage === 'complete') { return playerQuest.progress === 'complete'; } - if(typeof playerQuest.progress === 'number') { - if(actionHook.questRequirement.stage !== undefined) { - if(!numberHookFilter(actionHook.questRequirement.stage, playerQuest.progress)) { - return false; - } - } else if(actionHook.questRequirement.stages !== undefined) { - if(!numberHookFilter(actionHook.questRequirement.stages, playerQuest.progress)) { - return false; - } + if (typeof playerQuest.progress === 'number') { + if (actionHook.questRequirement.stage !== undefined) { + return numberHookFilter(actionHook.questRequirement.stage, playerQuest.progress); + } else if (actionHook.questRequirement.stages !== undefined) { + return numberHookFilter(actionHook.questRequirement.stages, playerQuest.progress); } } diff --git a/src/game-engine/world/actor/actor.ts b/src/game-engine/world/actor/actor.ts index 617953686..329359dd4 100644 --- a/src/game-engine/world/actor/actor.ts +++ b/src/game-engine/world/actor/actor.ts @@ -1,715 +1,738 @@ -import { WalkingQueue } from './walking-queue'; -import { ItemContainer } from '../items/item-container'; -import { Animation, DamageType, Graphic, UpdateFlags } from './update-flags'; -import { Npc } from './npc/npc'; -import { Skill, Skills } from '@engine/world/actor/skills'; -import { Item } from '@engine/world/items/item'; -import { Position } from '@engine/world/position'; -import { DirectionData, directionFromIndex } from '@engine/world/direction'; -import { Pathfinding } from '@engine/world/actor/pathfinding'; -import { Subject } from 'rxjs'; -import { filter, take } from 'rxjs/operators'; -import { world } from '@engine/game-server'; -import { WorldInstance } from '@engine/world/instances'; -import { Player } from '@engine/world/actor/player/player'; -import { ActionCancelType, ActionPipeline } from '@engine/world/action'; -import { LandscapeObject } from '@runejs/filestore'; -import { Behavior } from './behaviors/behavior'; -import { soundIds } from '../config/sound-ids'; -import { animationIds } from '../config/animation-ids'; -import { findNpc } from '../../config'; -import { itemIds } from '../config/item-ids'; -import { Attack, AttackDamageType } from './player/attack'; -import { Effect, EffectType } from './effect'; - -/** - * Handles an actor within the game world. - */ -export abstract class Actor { - - - - public readonly updateFlags: UpdateFlags = new UpdateFlags(); - public readonly skills: Skills = new Skills(this); - public readonly walkingQueue: WalkingQueue = new WalkingQueue(this); - public readonly inventory: ItemContainer = new ItemContainer(28); - public readonly bank: ItemContainer = new ItemContainer(376); - public readonly actionPipeline = new ActionPipeline(this); - public readonly metadata: { [key: string]: any } = {}; - - /** - * @deprecated - use new action system instead - */ - public readonly actionsCancelled: Subject = new Subject(); - - public pathfinding: Pathfinding = new Pathfinding(this); - public lastMovementPosition: Position; - // #region Behaviors and Combat flags/checks - public inCombat: boolean = false; - public meleeDistance: number = 1; - public Behaviors: Behavior[] = []; - public isDead: boolean = false; - public combatTargets: Actor[] = []; - public hitPoints = this.skills.hitpoints.level * 4; - public maxHitPoints = this.skills.hitpoints.level * 4; - - public get damageType() { - return this._damageType; - } - public set damageType(value) { - this._damageType = value; - } - public effects: Effect[] = []; //spells, effects, prayers, etc - - protected randomMovementInterval; - /** - * @deprecated - use new action system instead - */ - private _busy: boolean; - private _position: Position; - private _lastMapRegionUpdatePosition: Position; - private _worldIndex: number; - private _walkDirection: number; - private _runDirection: number; - private _faceDirection: number; - private _instance: WorldInstance = null; - private _damageType = AttackDamageType.Crush; - - protected constructor() { - this._walkDirection = -1; - this._runDirection = -1; - this._faceDirection = 6; - this._busy = false; - } - - public get highestCombatSkill(): Skill { - const attack = this.skills.getLevel('attack'); - const magic = this.skills.getLevel('magic'); - const ranged = this.skills.getLevel('ranged'); - - if (ranged > magic && ranged > ranged) return ranged; - else if (magic > attack && magic > ranged) return magic; - else return attack; - } - - //https://oldschool.runescape.wiki/w/Attack_range#:~:text=All%20combat%20magic%20spells%20have,also%20allow%20longrange%20attack%20style - // range should be within 10 tiles for magic - // range should be within 7 for magic staff - // https://www.theoatrix.net/post/how-defence-works-in-osrs - // https://oldschool.runescape.wiki/w/Damage_per_second/Magic - // https://oldschool.runescape.wiki/w/Successful_hit - // https://oldschool.runescape.wiki/w/Combat_level#:~:text=Calculating%20combat%20level,-Simply&text=Add%20your%20Strength%20and%20Attack,have%20your%20melee%20combat%20level.&text=Multiply%20this%20by%200.325%20and,have%20your%20magic%20combat%20level - // https://oldschool.runescape.wiki/w/Damage_per_second/Melee#:~:text=1%20Step%20one%3A%20Calculate%20the%20effective%20strength%20level%3B,1.7%20Step%20seven%3A%20Calculate%20the%20melee%20damage%20output - public getAttackRoll(defender): Attack { - - //the amount of damage is random from 0 to Max - //stance modifiers - const _stance_defense = 3; - const _stance_accurate = 0; - const _stance_controlled = 1; - - // base level - // ToDo: calculate prayer effects - // round decimal result calulcation up - // add 8 - // ToDo: add void bonues (effects) - // round result down - let equipmentBonus = 0; - if (this.isPlayer) { - const player = (this as unknown as Player); - equipmentBonus = player.bonuses.offensive.crush; - } - if (equipmentBonus == 0) equipmentBonus = 1; - /* - * To calculate your maximum hit: +import { WalkingQueue } from './walking-queue'; +import { ItemContainer } from '../items/item-container'; +import { Animation, DamageType, Graphic, UpdateFlags } from './update-flags'; +import { Npc } from './npc/npc'; +import { Skill, Skills } from '@engine/world/actor/skills'; +import { Item } from '@engine/world/items/item'; +import { Position } from '@engine/world/position'; +import { DirectionData, directionFromIndex } from '@engine/world/direction'; +import { Pathfinding } from '@engine/world/actor/pathfinding'; +import { Subject } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; +import { world } from '@engine/game-server'; +import { WorldInstance } from '@engine/world/instances'; +import { Player } from '@engine/world/actor/player/player'; +import { ActionCancelType, ActionPipeline } from '@engine/world/action'; +import { World } from '@engine/world'; +import { LandscapeObject } from '@runejs/filestore'; +import { Behavior } from './behaviors/behavior'; +import { soundIds } from '../config/sound-ids'; +import { animationIds } from '../config/animation-ids'; +import { findNpc } from '../../config'; +import { itemIds } from '../config/item-ids'; +import { Attack, AttackDamageType } from './player/attack'; +import { Effect, EffectType } from './effect'; + +/** + * Handles an actor within the game world. + */ +export abstract class Actor { + + + + public readonly updateFlags: UpdateFlags = new UpdateFlags(); + public readonly skills: Skills = new Skills(this); + public readonly walkingQueue: WalkingQueue = new WalkingQueue(this); + public readonly inventory: ItemContainer = new ItemContainer(28); + public readonly bank: ItemContainer = new ItemContainer(376); + public readonly actionPipeline = new ActionPipeline(this); + public readonly metadata: { [key: string]: any } = {}; + + /** + * @deprecated - use new action system instead + */ + public readonly actionsCancelled: Subject = new Subject(); + + public pathfinding: Pathfinding = new Pathfinding(this); + public lastMovementPosition: Position; + // #region Behaviors and Combat flags/checks + public inCombat: boolean = false; + public meleeDistance: number = 1; + public Behaviors: Behavior[] = []; + public isDead: boolean = false; + public combatTargets: Actor[] = []; + public hitPoints = this.skills.hitpoints.level * 4; + public maxHitPoints = this.skills.hitpoints.level * 4; + + public get damageType() { + return this._damageType; + } + public set damageType(value) { + this._damageType = value; + } + public effects: Effect[] = []; //spells, effects, prayers, etc + + protected randomMovementInterval; + /** + * @deprecated - use new action system instead + */ + private _busy: boolean; + private _position: Position; + private _lastMapRegionUpdatePosition: Position; + private _worldIndex: number; + private _walkDirection: number; + private _runDirection: number; + private _faceDirection: number; + private _instance: WorldInstance = null; + private _damageType = AttackDamageType.Crush; + + protected constructor() { + this._walkDirection = -1; + this._runDirection = -1; + this._faceDirection = 6; + this._busy = false; + } + + public get highestCombatSkill(): Skill { + const attack = this.skills.getLevel('attack'); + const magic = this.skills.getLevel('magic'); + const ranged = this.skills.getLevel('ranged'); + + if (ranged > magic && ranged > ranged) return ranged; + else if (magic > attack && magic > ranged) return magic; + else return attack; + } - Effective strength level - Multiply by(Equipment Melee Strength + 64) - Add 320 - Divide by 640 + //https://oldschool.runescape.wiki/w/Attack_range#:~:text=All%20combat%20magic%20spells%20have,also%20allow%20longrange%20attack%20style + // range should be within 10 tiles for magic + // range should be within 7 for magic staff + // https://www.theoatrix.net/post/how-defence-works-in-osrs + // https://oldschool.runescape.wiki/w/Damage_per_second/Magic + // https://oldschool.runescape.wiki/w/Successful_hit + // https://oldschool.runescape.wiki/w/Combat_level#:~:text=Calculating%20combat%20level,-Simply&text=Add%20your%20Strength%20and%20Attack,have%20your%20melee%20combat%20level.&text=Multiply%20this%20by%200.325%20and,have%20your%20magic%20combat%20level + // https://oldschool.runescape.wiki/w/Damage_per_second/Melee#:~:text=1%20Step%20one%3A%20Calculate%20the%20effective%20strength%20level%3B,1.7%20Step%20seven%3A%20Calculate%20the%20melee%20damage%20output + public getAttackRoll(defender): Attack { + + //the amount of damage is random from 0 to Max + //stance modifiers + const _stance_defense = 3; + const _stance_accurate = 0; + const _stance_controlled = 1; + + // base level + // ToDo: calculate prayer effects + // round decimal result calulcation up + // add 8 + // ToDo: add void bonues (effects) + // round result down + let equipmentBonus = 0; + if (this.isPlayer) { + const player = (this as unknown as Player); + equipmentBonus = player.bonuses.offensive.crush; + } + if (equipmentBonus == 0) equipmentBonus = 1; + /* + * To calculate your maximum hit: + + Effective strength level + Multiply by(Equipment Melee Strength + 64) + Add 320 + Divide by 640 + Round down to nearest integer + Multiply by gear bonus Round down to nearest integer - Multiply by gear bonus - Round down to nearest integer - */ - const stanceModifier = _stance_accurate; - const strengthLevel = (this.skills.attack.level + stanceModifier + 8); - let attackCalc = strengthLevel * (equipmentBonus + 64) + 320; - attackCalc = Math.round(attackCalc / 640); - //console.log(`strengthLevel = ${strengthLevel} \r\n attackCalc = ${attackCalc} \r\n equipmentBonus = ${equipmentBonus}`); - const maximumHit = Math.round(attackCalc * equipmentBonus); - - /* - To calculate your effective attack level: - - (Attack level + Attack level boost) * prayer bonus + */ + const stanceModifier = _stance_accurate; + const strengthLevel = (this.skills.attack.level + stanceModifier + 8); + let attackCalc = strengthLevel * (equipmentBonus + 64) + 320; + attackCalc = Math.round(attackCalc / 640); + //console.log(`strengthLevel = ${strengthLevel} \r\n attackCalc = ${attackCalc} \r\n equipmentBonus = ${equipmentBonus}`); + const maximumHit = Math.round(attackCalc * equipmentBonus); + + /* + To calculate your effective attack level: + + (Attack level + Attack level boost) * prayer bonus + Round down to nearest integer + + 3 if using the accurate attack style, +1 if using controlled + + 8 + Multiply by 1.1 if wearing void + Round down to nearest integer + */ + const attackLevel = this.skills.attack.level; + let effectiveAttackLevel = attackLevel; + + //Prayer/Effect bonus - calculate ALL the good and bad effects at once! (prayers, and magic effects, etc.) + this.effects.filter(a => a.EffectType === EffectType.Attack).forEach((effect) => { + effectiveAttackLevel += (attackLevel * effect.Modifier); + }); + effectiveAttackLevel = Math.round(effectiveAttackLevel) + stanceModifier; + + /* + * Calculate the Attack roll + Effective attack level * (Equipment Attack bonus + 64) + Multiply by gear bonus Round down to nearest integer - + 3 if using the accurate attack style, +1 if using controlled - + 8 - Multiply by 1.1 if wearing void - Round down to nearest integer - */ - const attackLevel = this.skills.attack.level; - let effectiveAttackLevel = attackLevel; - - //Prayer/Effect bonus - calculate ALL the good and bad effects at once! (prayers, and magic effects, etc.) - this.effects.filter(a => a.EffectType === EffectType.Attack).forEach((effect) => { - effectiveAttackLevel += (attackLevel * effect.Modifier); - }); - effectiveAttackLevel = Math.round(effectiveAttackLevel) + stanceModifier; - - /* - * Calculate the Attack roll - Effective attack level * (Equipment Attack bonus + 64) - Multiply by gear bonus - Round down to nearest integer - * */ - let attack = new Attack(); - attack.damageType = this.damageType ?? AttackDamageType.Crush; - attack.attackRoll = Math.round(effectiveAttackLevel * (equipmentBonus + 64)); - attack = defender.getDefenseRoll(attack); - attack.maximumHit = maximumHit; - if (attack.attackRoll >= attack.defenseRoll) attack.hitChance = 1 - ((attack.defenseRoll + 2) / (2 * (attack.attackRoll + 1))) - if (attack.attackRoll < attack.defenseRoll) attack.hitChance = attack.attackRoll / (2 * attack.defenseRoll + 1); - - attack.damage = Math.round((maximumHit * attack.hitChance) / 2); - return attack; - } - public getDefenseRoll(attack: Attack): Attack { - //attack need to know the damage roll, which is the item bonuses the weapon damage type etc. - - - //stance modifiers - const _stance_defense = 3; - const _stance_accurate = 0; - const _stance_controlled = 1; - - // base level - // calculate prayer effects - // round decimal result calulcation up - // add 8 - // ToDo: add void bonues (effects) - // round result down - - const equipmentBonus: number = this.isPlayer ? (this as unknown as Player).bonuses.defensive.crush : 0; //object prototyping to find property by name (JS style =/) - - const stanceModifier: number = _stance_accurate; - - - attack.defenseRoll = (this.skills.defence.level + stanceModifier + 8) * (equipmentBonus + 64); - //Prayer/Effect bonus - calculate ALL the good and bad effects at once! (prayers, and magic effects, etc.) - this.effects.filter(a => a.EffectType === EffectType.BoostDefense || a.EffectType === EffectType.LowerDefense).forEach((effect) => { - attack.defenseRoll += (this.skills.defence.level * effect.Modifier); - }); - attack.defenseRoll = Math.round(attack.defenseRoll); - return attack; - //+ stance modifier - } - // #endregion - - public damage(amount: number, damageType: DamageType = DamageType.DAMAGE) { - const armorReduction = 0; - const spellDamageReduction = 0; - const poisonReistance = 0; - amount -= armorReduction; - this.hitPoints -= amount; - this.skills.setHitpoints(this.hitPoints); - this.updateFlags.addDamage(amount, amount === 0 ? DamageType.NO_DAMAGE : damageType, - this.hitPoints, this.maxHitPoints); - //this actor should respond when hit - world.playLocationSound(this.position, soundIds.npc.human.noArmorHitPlayer,5) - this.playAnimation(this.getBlockAnimation()); - } - - - - //public damage(amount: number, damageType: DamageType = DamageType.DAMAGE): 'alive' | 'dead' { - // let remainingHitpoints: number = this.skills.hitpoints.level - amount; - // const maximumHitpoints: number = this.skills.hitpoints.levelForExp; - // if(remainingHitpoints < 0) { - // remainingHitpoints = 0; - // } - - // this.skills.setHitpoints(remainingHitpoints); - // this.updateFlags.addDamage(amount, amount === 0 ? DamageType.NO_DAMAGE : damageType, - // remainingHitpoints, maximumHitpoints); - - // return remainingHitpoints === 0 ? 'dead' : 'alive'; - //} - - /** - * Waits for the actor to reach the specified position before resolving it's promise. - * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. - * @param position The position that the actor needs to reach for the promise to resolve. - */ - public async waitForPathing(position: Position): Promise; - - /** - * Waits for the actor to reach the specified game object before resolving it's promise. - * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. - * @param gameObject The game object to wait for the actor to reach. - */ - public async waitForPathing(gameObject: LandscapeObject): Promise; - - /** - * Waits for the actor to reach the specified game object before resolving it's promise. - * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. - * @param target The position or game object that the actor needs to reach for the promise to resolve. - */ - public async waitForPathing(target: Position | LandscapeObject): Promise; - public async waitForPathing(target: Position | LandscapeObject): Promise { - if(this.position.withinInteractionDistance(target)) { - return; - } - - await new Promise((resolve, reject) => { - this.metadata.walkingTo = target instanceof Position ? target : new Position(target.x, target.y, target.level); - - const inter = setInterval(() => { - if(!this.metadata.walkingTo || !this.metadata.walkingTo.equals(target)) { - reject(); - clearInterval(inter); - return; - } - - if(!this.walkingQueue.moving()) { - if(target instanceof Position) { - if(this.position.distanceBetween(target) > 1) { - reject(); - } else { - resolve(); - } - } else { - if(this.position.withinInteractionDistance(target)) { - resolve(); - } else { - reject(); - } - } - - clearInterval(inter); - this.metadata.walkingTo = null; - } - }, 100); - }); - } - - public async moveBehind(target: Actor): Promise { - if(this.position.level !== target.position.level) { - return false; - } - - const distance = Math.floor(this.position.distanceBetween(target.position)); - if(distance > 16) { - this.clearFaceActor(); - return false; - } - - let ignoreDestination = true; - let desiredPosition = target.position; - if(target.lastMovementPosition) { - desiredPosition = target.lastMovementPosition; - ignoreDestination = false; - } - - await this.pathfinding.walkTo(desiredPosition, { - pathingSearchRadius: distance + 2, - ignoreDestination - }); - - return true; - } - - public async moveTo(target: Actor): Promise { - if(this.position.level !== target.position.level) { - return false; - } - - const distance = Math.floor(this.position.distanceBetween(target.position)); - if(distance > 16) { - this.clearFaceActor(); - return false; - } - - await this.pathfinding.walkTo(target.position, { - pathingSearchRadius: distance + 2, - ignoreDestination: true - }); - - return true; - } - - public follow(target: Actor): void { - this.face(target, false, false, false); - this.metadata['following'] = target; - - this.moveBehind(target); - const subscription = target.walkingQueue.movementEvent.subscribe(() => { - if(!this.moveBehind(target)) { - this.actionsCancelled.next(null); - } - }); - - this.actionsCancelled.pipe( - filter(type => type !== 'pathing-movement'), - take(1) - ).subscribe(() => { - subscription.unsubscribe(); - this.face(null); - delete this.metadata['following']; - }); - } - - public async walkTo(target: Actor): Promise; - public async walkTo(position: Position): Promise; - public async walkTo(target: Actor | Position): Promise { - const desiredPosition = target instanceof Position ? target : target.position; - - const distance = Math.floor(this.position.distanceBetween(desiredPosition)); - - if(distance <= 1) { - return false; - } - - if(distance > 16) { - this.clearFaceActor(); - this.metadata.faceActorClearedByWalking = true; - return false; - } - - await this.pathfinding.walkTo(desiredPosition, { - pathingSearchRadius: distance + 2, - ignoreDestination: true - }); - - return true; - } - - public tail(target: Actor): void { - this.face(target, false, false, false); - - if(this.metadata.tailing && this.metadata.tailing.equals(target)) { - return; - } - - this.metadata['tailing'] = target; - - this.moveTo(target); - const subscription = target.walkingQueue.movementEvent.subscribe(async () => this.moveTo(target)); - - this.actionsCancelled.pipe( - filter(type => type !== 'pathing-movement'), - take(1) - ).subscribe(() => { - subscription.unsubscribe(); - this.face(null); - delete this.metadata['tailing']; - }); - } - - public face(face: Position | Actor | null, clearWalkingQueue: boolean = true, autoClear: boolean = true, clearedByWalking: boolean = true): void { - if(face === null) { - this.clearFaceActor(); - this.updateFlags.facePosition = null; - return; - } - - if(face instanceof Position) { - this.updateFlags.facePosition = face; - } else if(face instanceof Actor) { - this.updateFlags.faceActor = face; - this.metadata['faceActor'] = face; - this.metadata['faceActorClearedByWalking'] = clearedByWalking; - - if(autoClear) { - setTimeout(() => { - this.clearFaceActor(); - }, 20000); - } - } - - if(clearWalkingQueue) { - this.walkingQueue.clear(); - this.walkingQueue.valid = false; - } - } - - public clearFaceActor(): void { - if(this.metadata['faceActor']) { - this.updateFlags.faceActor = null; - this.metadata['faceActor'] = undefined; - } - } - - public playAnimation(animation: number | Animation): void { - if(typeof animation === 'number') { - animation = { id: animation, delay: 0 }; - } - - this.updateFlags.animation = animation; - } - - public stopAnimation(): void { - this.updateFlags.animation = { id: -1, delay: 0 }; - } - - public playGraphics(graphics: number | Graphic): void { - if(typeof graphics === 'number') { - graphics = { id: graphics, delay: 0, height: 120 }; - } - - this.updateFlags.graphics = graphics; - } - - public stopGraphics(): void { - this.updateFlags.graphics = { id: -1, delay: 0, height: 120 }; - } - - public removeItem(slot: number): void { - this.inventory.remove(slot); - } - - public removeBankItem(slot: number): void { - this.bank.remove(slot); - } - - public giveItem(item: number | Item): boolean { - return this.inventory.add(item) !== null; - } - public giveBankItem(item: number | Item): boolean { - return this.bank.add(item) !== null; - } - - public hasItemInInventory(item: number | Item): boolean { - return this.inventory.has(item); - } - public hasItemInBank(item: number | Item): boolean { - return this.bank.has(item); - } - - public hasItemOnPerson(item: number | Item): boolean { - return this.hasItemInInventory(item); - } - - public canMove(): boolean { - return !this.busy; - } - - public initiateRandomMovement(): void { - this.randomMovementInterval = setInterval(() => this.moveSomewhere(), 1000); - } - - public moveSomewhere(): void { - if(!this.canMove()) { - return; - } - - if(this instanceof Npc) { - const nearbyPlayers = world.findNearbyPlayers(this.position, 24, this.instanceId); - if(nearbyPlayers.length === 0) { - // No need for this NPC to move if there are no players nearby to see it - return; - } - } - - const movementChance = Math.floor(Math.random() * 10); - - if(movementChance < 7) { - return; - } - - let px: number; - let py: number; - let movementAllowed = false; - - while(!movementAllowed) { - px = this.position.x; - py = this.position.y; - - const moveXChance = Math.floor(Math.random() * 10); - - if(moveXChance > 6) { - const moveXAmount = Math.floor(Math.random() * 5); - const moveXMod = Math.floor(Math.random() * 2); - - if(moveXMod === 0) { - px -= moveXAmount; - } else { - px += moveXAmount; - } - } - - const moveYChance = Math.floor(Math.random() * 10); - - if(moveYChance > 6) { - const moveYAmount = Math.floor(Math.random() * 5); - const moveYMod = Math.floor(Math.random() * 2); - - if(moveYMod === 0) { - py -= moveYAmount; - } else { - py += moveYAmount; - } - } - - let valid = true; - - if(this instanceof Npc) { - if(px > this.initialPosition.x + this.movementRadius || px < this.initialPosition.x - this.movementRadius - || py > this.initialPosition.y + this.movementRadius || py < this.initialPosition.y - this.movementRadius) { - valid = false; - } - } - - movementAllowed = valid; - } - - if(px !== this.position.x || py !== this.position.y) { - this.walkingQueue.clear(); - this.walkingQueue.valid = true; - this.walkingQueue.add(px, py); - } - } - - public forceMovement(direction: number, steps: number): void { - if(!this.canMove()) { - return; - } - - let px: number; - let py: number; - let movementAllowed = false; - - while(!movementAllowed) { - px = this.position.x; - py = this.position.y; - - const movementDirection: DirectionData = directionFromIndex(direction); - if(!movementDirection) { - return; - } - let valid = true; - for(let step = 0; step < steps; step++) { - px += movementDirection.deltaX; - py += movementDirection.deltaY; - - if(this instanceof Npc) { - if(px > this.initialPosition.x + this.movementRadius || px < this.initialPosition.x - this.movementRadius - || py > this.initialPosition.y + this.movementRadius || py < this.initialPosition.y - this.movementRadius) { - valid = false; - } - } - - } - - movementAllowed = valid; - - - } - - if(px !== this.position.x || py !== this.position.y) { - this.walkingQueue.clear(); - this.walkingQueue.valid = true; - this.walkingQueue.add(px, py); - } - } - - public abstract getAttackAnimation(): number; - public abstract getBlockAnimation(): number; - public abstract equals(actor: Actor): boolean; - - public get position(): Position { - return this._position; - } - - public set position(value: Position) { - if(!this._position) { - this._lastMapRegionUpdatePosition = value; - } - - this._position = value; - } - - public get lastMapRegionUpdatePosition(): Position { - return this._lastMapRegionUpdatePosition; - } - - public set lastMapRegionUpdatePosition(value: Position) { - this._lastMapRegionUpdatePosition = value; - } - - public get worldIndex(): number { - return this._worldIndex; - } - - public set worldIndex(value: number) { - this._worldIndex = value; - } - - public get walkDirection(): number { - return this._walkDirection; - } - - public set walkDirection(value: number) { - this._walkDirection = value; - } - - public get runDirection(): number { - return this._runDirection; - } - - public set runDirection(value: number) { - this._runDirection = value; - } - - public get faceDirection(): number { - return this._faceDirection; - } - - public set faceDirection(value: number) { - this._faceDirection = value; - } - - public get busy(): boolean { - return this._busy; - } - - public set busy(value: boolean) { - this._busy = value; - } - - public get instance(): WorldInstance { - return this._instance || world.globalInstance; - } - - public set instance(value: WorldInstance) { - if(this instanceof Player) { - const currentInstance = this._instance; - if(currentInstance?.instanceId) { - currentInstance.removePlayer(this); - } - - if(value) { - value.addPlayer(this); - } - } - - this._instance = value; - } - - public get isPlayer(): boolean { - return this instanceof Player; - } - - public get isNpc(): boolean { - return this instanceof Npc; - } - - public get type(): { player?: Player, npc?: Npc } { - return { - player: this.isPlayer ? this as unknown as Player : undefined, - npc: this.isNpc ? this as unknown as Npc : undefined - }; - } - - -} + * */ + let attack = new Attack(); + attack.damageType = this.damageType ?? AttackDamageType.Crush; + attack.attackRoll = Math.round(effectiveAttackLevel * (equipmentBonus + 64)); + attack = defender.getDefenseRoll(attack); + attack.maximumHit = maximumHit; + if (attack.attackRoll >= attack.defenseRoll) attack.hitChance = 1 - ((attack.defenseRoll + 2) / (2 * (attack.attackRoll + 1))) + if (attack.attackRoll < attack.defenseRoll) attack.hitChance = attack.attackRoll / (2 * attack.defenseRoll + 1); + + attack.damage = Math.round((maximumHit * attack.hitChance) / 2); + return attack; + } + public getDefenseRoll(attack: Attack): Attack { + //attack need to know the damage roll, which is the item bonuses the weapon damage type etc. + + + //stance modifiers + const _stance_defense = 3; + const _stance_accurate = 0; + const _stance_controlled = 1; + + // base level + // calculate prayer effects + // round decimal result calulcation up + // add 8 + // ToDo: add void bonues (effects) + // round result down + + const equipmentBonus: number = this.isPlayer ? (this as unknown as Player).bonuses.defensive.crush : 0; //object prototyping to find property by name (JS style =/) + + const stanceModifier: number = _stance_accurate; + + + attack.defenseRoll = (this.skills.defence.level + stanceModifier + 8) * (equipmentBonus + 64); + //Prayer/Effect bonus - calculate ALL the good and bad effects at once! (prayers, and magic effects, etc.) + this.effects.filter(a => a.EffectType === EffectType.BoostDefense || a.EffectType === EffectType.LowerDefense).forEach((effect) => { + attack.defenseRoll += (this.skills.defence.level * effect.Modifier); + }); + attack.defenseRoll = Math.round(attack.defenseRoll); + return attack; + //+ stance modifier + } + // #endregion + + public damage(amount: number, damageType: DamageType = DamageType.DAMAGE) { + const armorReduction = 0; + const spellDamageReduction = 0; + const poisonReistance = 0; + amount -= armorReduction; + this.hitPoints -= amount; + this.skills.setHitpoints(this.hitPoints); + this.updateFlags.addDamage(amount, amount === 0 ? DamageType.NO_DAMAGE : damageType, + this.hitPoints, this.maxHitPoints); + //this actor should respond when hit + world.playLocationSound(this.position, soundIds.npc.human.noArmorHitPlayer,5) + this.playAnimation(this.getBlockAnimation()); + } + + + + //public damage(amount: number, damageType: DamageType = DamageType.DAMAGE): 'alive' | 'dead' { + // let remainingHitpoints: number = this.skills.hitpoints.level - amount; + // const maximumHitpoints: number = this.skills.hitpoints.levelForExp; + // if(remainingHitpoints < 0) { + // remainingHitpoints = 0; + // } + + // this.skills.setHitpoints(remainingHitpoints); + // this.updateFlags.addDamage(amount, amount === 0 ? DamageType.NO_DAMAGE : damageType, + // remainingHitpoints, maximumHitpoints); + + // return remainingHitpoints === 0 ? 'dead' : 'alive'; + //} + + /** + * Waits for the actor to reach the specified position before resolving it's promise. + * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. + * @param position The position that the actor needs to reach for the promise to resolve. + */ + public async waitForPathing(position: Position): Promise; + + /** + * Waits for the actor to reach the specified game object before resolving it's promise. + * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. + * @param gameObject The game object to wait for the actor to reach. + */ + public async waitForPathing(gameObject: LandscapeObject): Promise; + + /** + * Waits for the actor to reach the specified game object before resolving it's promise. + * The promise will be rejected if the actor's walking queue changes or their movement is otherwise canceled. + * @param target The position or game object that the actor needs to reach for the promise to resolve. + */ + public async waitForPathing(target: Position | LandscapeObject): Promise; + public async waitForPathing(target: Position | LandscapeObject): Promise { + if(this.position.withinInteractionDistance(target)) { + return; + } + + await new Promise((resolve, reject) => { + this.metadata.walkingTo = target instanceof Position ? target : new Position(target.x, target.y, target.level); + + const inter = setInterval(() => { + if(!this.metadata.walkingTo || !this.metadata.walkingTo.equals(target)) { + reject(); + clearInterval(inter); + return; + } + + if(!this.walkingQueue.moving()) { + if(target instanceof Position) { + if(this.position.distanceBetween(target) > 1) { + reject(); + } else { + resolve(); + } + } else { + if(this.position.withinInteractionDistance(target)) { + resolve(); + } else { + reject(); + } + } + + clearInterval(inter); + this.metadata.walkingTo = null; + } + }, 100); + }); + } + + public async moveBehind(target: Actor): Promise { + if(this.position.level !== target.position.level) { + return false; + } + + const distance = Math.floor(this.position.distanceBetween(target.position)); + if(distance > 16) { + this.clearFaceActor(); + return false; + } + + let ignoreDestination = true; + let desiredPosition = target.position; + if(target.lastMovementPosition) { + desiredPosition = target.lastMovementPosition; + ignoreDestination = false; + } + + await this.pathfinding.walkTo(desiredPosition, { + pathingSearchRadius: distance + 2, + ignoreDestination + }); + + return true; + } + + public async moveTo(target: Actor): Promise { + if(this.position.level !== target.position.level) { + return false; + } + + const distance = Math.floor(this.position.distanceBetween(target.position)); + if(distance > 16) { + this.clearFaceActor(); + return false; + } + + await this.pathfinding.walkTo(target.position, { + pathingSearchRadius: distance + 2, + ignoreDestination: true + }); + + return true; + } + + /** + * Wait for actor movement to finish in a Promise-like fashion. + * @param timeoutInTicks How long to wait for in ticks before rejecting the promise + */ + public async isIdle(timeoutInTicks = 100): Promise { + return new Promise((resolve, reject) => { + let ticksChecked = 0; + const checkIsMoving = setInterval(() => { + ticksChecked++; + if (!this.walkingQueue.moving()) { + resolve(); + clearInterval(checkIsMoving); + } + + if (ticksChecked >= timeoutInTicks) { + reject(); + clearInterval(checkIsMoving); + } + }, World.TICK_LENGTH) + }); + } + + public follow(target: Actor): void { + this.face(target, false, false, false); + this.metadata['following'] = target; + + this.moveBehind(target); + const subscription = target.walkingQueue.movementEvent.subscribe(() => { + if(!this.moveBehind(target)) { + this.actionsCancelled.next(null); + } + }); + + this.actionsCancelled.pipe( + filter(type => type !== 'pathing-movement'), + take(1) + ).subscribe(() => { + subscription.unsubscribe(); + this.face(null); + delete this.metadata['following']; + }); + } + + public async walkTo(target: Actor): Promise; + public async walkTo(position: Position): Promise; + public async walkTo(target: Actor | Position): Promise { + const desiredPosition = target instanceof Position ? target : target.position; + + const distance = Math.floor(this.position.distanceBetween(desiredPosition)); + + if(distance <= 1) { + return false; + } + + if(distance > 16) { + this.clearFaceActor(); + this.metadata.faceActorClearedByWalking = true; + return false; + } + + await this.pathfinding.walkTo(desiredPosition, { + pathingSearchRadius: distance + 2, + ignoreDestination: true + }); + + return true; + } + + public tail(target: Actor): void { + this.face(target, false, false, false); + + if(this.metadata.tailing && this.metadata.tailing.equals(target)) { + return; + } + + this.metadata['tailing'] = target; + + this.moveTo(target); + const subscription = target.walkingQueue.movementEvent.subscribe(async () => this.moveTo(target)); + + this.actionsCancelled.pipe( + filter(type => type !== 'pathing-movement'), + take(1) + ).subscribe(() => { + subscription.unsubscribe(); + this.face(null); + delete this.metadata['tailing']; + }); + } + + public face(face: Position | Actor | null, clearWalkingQueue: boolean = true, autoClear: boolean = true, clearedByWalking: boolean = true): void { + if(face === null) { + this.clearFaceActor(); + this.updateFlags.facePosition = null; + return; + } + + if(face instanceof Position) { + this.updateFlags.facePosition = face; + } else if(face instanceof Actor) { + this.updateFlags.faceActor = face; + this.metadata['faceActor'] = face; + this.metadata['faceActorClearedByWalking'] = clearedByWalking; + + if(autoClear) { + setTimeout(() => { + this.clearFaceActor(); + }, 20000); + } + } + + if(clearWalkingQueue) { + this.walkingQueue.clear(); + this.walkingQueue.valid = false; + } + } + + public clearFaceActor(): void { + if(this.metadata['faceActor']) { + this.updateFlags.faceActor = null; + this.metadata['faceActor'] = undefined; + } + } + + public playAnimation(animation: number | Animation): void { + if(typeof animation === 'number') { + animation = { id: animation, delay: 0 }; + } + + this.updateFlags.animation = animation; + } + + public stopAnimation(): void { + this.updateFlags.animation = { id: -1, delay: 0 }; + } + + public playGraphics(graphics: number | Graphic): void { + if(typeof graphics === 'number') { + graphics = { id: graphics, delay: 0, height: 120 }; + } + + this.updateFlags.graphics = graphics; + } + + public stopGraphics(): void { + this.updateFlags.graphics = { id: -1, delay: 0, height: 120 }; + } + + public removeItem(slot: number): void { + this.inventory.remove(slot); + } + + public removeBankItem(slot: number): void { + this.bank.remove(slot); + } + + public giveItem(item: number | Item): boolean { + return this.inventory.add(item) !== null; + } + public giveBankItem(item: number | Item): boolean { + return this.bank.add(item) !== null; + } + + public hasItemInInventory(item: number | Item): boolean { + return this.inventory.has(item); + } + public hasItemInBank(item: number | Item): boolean { + return this.bank.has(item); + } + + public hasItemOnPerson(item: number | Item): boolean { + return this.hasItemInInventory(item); + } + + public canMove(): boolean { + return !this.busy; + } + + public initiateRandomMovement(): void { + this.randomMovementInterval = setInterval(() => this.moveSomewhere(), 1000); + } + + public moveSomewhere(): void { + if(!this.canMove()) { + return; + } + + if(this instanceof Npc) { + const nearbyPlayers = world.findNearbyPlayers(this.position, 24, this.instanceId); + if(nearbyPlayers.length === 0) { + // No need for this NPC to move if there are no players nearby to see it + return; + } + } + + const movementChance = Math.floor(Math.random() * 10); + + if(movementChance < 7) { + return; + } + + let px: number; + let py: number; + let movementAllowed = false; + + while(!movementAllowed) { + px = this.position.x; + py = this.position.y; + + const moveXChance = Math.floor(Math.random() * 10); + + if(moveXChance > 6) { + const moveXAmount = Math.floor(Math.random() * 5); + const moveXMod = Math.floor(Math.random() * 2); + + if(moveXMod === 0) { + px -= moveXAmount; + } else { + px += moveXAmount; + } + } + + const moveYChance = Math.floor(Math.random() * 10); + + if(moveYChance > 6) { + const moveYAmount = Math.floor(Math.random() * 5); + const moveYMod = Math.floor(Math.random() * 2); + + if(moveYMod === 0) { + py -= moveYAmount; + } else { + py += moveYAmount; + } + } + + let valid = true; + + if(this instanceof Npc) { + if(px > this.initialPosition.x + this.movementRadius || px < this.initialPosition.x - this.movementRadius + || py > this.initialPosition.y + this.movementRadius || py < this.initialPosition.y - this.movementRadius) { + valid = false; + } + } + + movementAllowed = valid; + } + + if(px !== this.position.x || py !== this.position.y) { + this.walkingQueue.clear(); + this.walkingQueue.valid = true; + this.walkingQueue.add(px, py); + } + } + + public forceMovement(direction: number, steps: number): void { + if(!this.canMove()) { + return; + } + + let px: number; + let py: number; + let movementAllowed = false; + + while(!movementAllowed) { + px = this.position.x; + py = this.position.y; + + const movementDirection: DirectionData = directionFromIndex(direction); + if(!movementDirection) { + return; + } + let valid = true; + for(let step = 0; step < steps; step++) { + px += movementDirection.deltaX; + py += movementDirection.deltaY; + + if(this instanceof Npc) { + if(px > this.initialPosition.x + this.movementRadius || px < this.initialPosition.x - this.movementRadius + || py > this.initialPosition.y + this.movementRadius || py < this.initialPosition.y - this.movementRadius) { + valid = false; + } + } + + } + + movementAllowed = valid; + + + } + + if(px !== this.position.x || py !== this.position.y) { + this.walkingQueue.clear(); + this.walkingQueue.valid = true; + this.walkingQueue.add(px, py); + } + } + + public abstract getAttackAnimation(): number; + public abstract getBlockAnimation(): number; + public abstract equals(actor: Actor): boolean; + + public get position(): Position { + return this._position; + } + + public set position(value: Position) { + if(!this._position) { + this._lastMapRegionUpdatePosition = value; + } + + this._position = value; + } + + public get lastMapRegionUpdatePosition(): Position { + return this._lastMapRegionUpdatePosition; + } + + public set lastMapRegionUpdatePosition(value: Position) { + this._lastMapRegionUpdatePosition = value; + } + + public get worldIndex(): number { + return this._worldIndex; + } + + public set worldIndex(value: number) { + this._worldIndex = value; + } + + public get walkDirection(): number { + return this._walkDirection; + } + + public set walkDirection(value: number) { + this._walkDirection = value; + } + + public get runDirection(): number { + return this._runDirection; + } + + public set runDirection(value: number) { + this._runDirection = value; + } + + public get faceDirection(): number { + return this._faceDirection; + } + + public set faceDirection(value: number) { + this._faceDirection = value; + } + + public get busy(): boolean { + return this._busy; + } + + public set busy(value: boolean) { + this._busy = value; + } + + public get instance(): WorldInstance { + return this._instance || world.globalInstance; + } + + public set instance(value: WorldInstance) { + if(this instanceof Player) { + const currentInstance = this._instance; + if(currentInstance?.instanceId) { + currentInstance.removePlayer(this); + } + + if(value) { + value.addPlayer(this); + } + } + + this._instance = value; + } + + public get isPlayer(): boolean { + return this instanceof Player; + } + + public get isNpc(): boolean { + return this instanceof Npc; + } + + public get type(): { player?: Player, npc?: Npc } { + return { + player: this.isPlayer ? this as unknown as Player : undefined, + npc: this.isNpc ? this as unknown as Npc : undefined + }; + } + + +} diff --git a/src/game-engine/world/actor/dialogue.ts b/src/game-engine/world/actor/dialogue.ts index e686b3d47..c85d252fb 100644 --- a/src/game-engine/world/actor/dialogue.ts +++ b/src/game-engine/world/actor/dialogue.ts @@ -4,7 +4,8 @@ import { filestore } from '@engine/game-server'; import { logger } from '@runejs/core'; import _ from 'lodash'; import { wrapText } from '@engine/util/strings'; -import { findNpc } from '@engine/config'; +import { findItem, findNpc } from '@engine/config'; +import { FontName, ParentWidget, TextWidget } from '@runejs/filestore'; export enum Emote { @@ -28,7 +29,8 @@ export enum Emote { BLANK_STARE = 'BLANK_STARE', SINGLE_WORD = 'SINGLE_WORD', EVIL_STARE = 'EVIL_STARE', - LAUGH_EVIL = 'LAUGH_EVIL' + LAUGH_EVIL = 'LAUGH_EVIL', + SLEEPING = 'SLEEPING', } // A big thanks to Dust R I P for all these emotes! @@ -101,18 +103,41 @@ enum EmoteAnimation { EASTER_BUNNY_2LINE = 1825, EASTER_BUNNY_3LINE = 1826, EASTER_BUNNY_4LINE = 1827, + SLEEPING_1LINE = 3321, } const nonLineEmotes = [ Emote.BLANK_STARE, Emote.SINGLE_WORD, Emote.EVIL_STARE, Emote.LAUGH_EVIL ]; -const playerWidgetIds = [ 64, 65, 66, 67 ]; -const npcWidgetIds = [ 241, 242, 243, 244 ]; -const optionWidgetIds = [ 228, 230, 232, 234, 235 ]; -const continuableTextWidgetIds = [ 210, 211, 212, 213, 214 ]; -const textWidgetIds = [ 215, 216, 217, 218, 219 ]; -const titledTextWidgetId = 372; - +export const playerWidgetIds = [ 64, 65, 66, 67 ]; // TODO: Non 'click here to continue' player widgets are missing! +export const npcWidgetIds = [ 241, 242, 243, 244 ]; // TODO: Non 'click here to continue' npc widgets are missing! +export const optionWidgetIds = [ 228, 230, 232, 234, 235 ]; +export const continuableTextWidgetIds = [ 210, 211, 212, 213, 214 ]; +export const textWidgetIds = [ 215, 216, 217, 218, 219 ]; +export const itemWidgetIds = [ 101, 102, 103, 104 ]; +export const titledTextWidgetId = 372; + +/** + * Wraps dialogue text into multiple lines. + * @param text - The text to wrap. + * @param type - 'ACTOR' if the widget has a chat-head or an item sprite on the left, 'TEXT' if the dialogue is text only + */ function wrapDialogueText(text: string, type: 'ACTOR' | 'TEXT'): string[] { - return wrapText(text, type === 'ACTOR' ? 340 : 430); + let widget: TextWidget; + let width = 0; + + switch (type) { + case 'ACTOR': + widget = (filestore.widgetStore.decodeWidget(playerWidgetIds[0]) as ParentWidget).children[2] as TextWidget; + width = widget.width; + break; + case 'TEXT': + widget = filestore.widgetStore.decodeWidget(textWidgetIds[0]) as TextWidget; + width = widget.width; + break; + default: + throw new Error(`Unhandled widget type: ${type}`); + } + + return wrapText(text, width, widget.fontId); } function parseDialogueFunctionArgs(func: Function): string[] { @@ -139,8 +164,8 @@ function parseDialogueFunctionArgs(func: Function): string[] { export type DialogueTree = (Function | DialogueFunction | GoToAction)[]; export interface AdditionalOptions { - closeOnWalk?: boolean; permanent?: boolean; + multi?: boolean; } interface NpcParticipant { @@ -173,6 +198,7 @@ class GoToAction implements DialogueAction { interface ActorDialogueAction extends DialogueAction { animation: number; lines: string[]; + customName?: string; } interface NpcDialogueAction extends ActorDialogueAction { @@ -188,6 +214,11 @@ interface TextDialogueAction extends DialogueAction { canContinue: boolean; } +interface ItemDialogueAction extends DialogueAction { + lines: string[]; + itemId: number | string; +} + interface TitledTextDialogueAction extends DialogueAction { title: string; lines: string[]; @@ -285,6 +316,12 @@ function parseDialogueTree(player: Player, npcParticipants: NpcParticipant[], di const text: string = dialogueAction(); const lines = wrapDialogueText(text, 'TEXT'); parsedDialogueTree.push({ lines, tag, type: 'TEXT', canContinue: true } as TextDialogueAction); + } else if(dialogueType === 'item') { + // Dialogue with an item on the left + + const [ itemId, text ] = dialogueAction(); + const lines = wrapDialogueText(text, 'ACTOR'); + parsedDialogueTree.push({ lines, tag, itemId, type: 'ITEM' } as ItemDialogueAction); } else if(dialogueType === 'overlay') { // Text-only dialogue (no option to continue). @@ -310,7 +347,7 @@ function parseDialogueTree(player: Player, npcParticipants: NpcParticipant[], di } else { // Player or Npc dialogue. - let dialogueDetails: [ Emote, string ]; + let dialogueDetails: [ Emote, string, string? ]; let npc: Npc | number | string; if(dialogueType !== 'player') { @@ -336,18 +373,19 @@ function parseDialogueTree(player: Player, npcParticipants: NpcParticipant[], di const emote = dialogueDetails[0] as Emote; const text = dialogueDetails[1] as string; + const customName = dialogueDetails[2] as string; const lines = wrapDialogueText(text, 'ACTOR'); const animation = nonLineEmotes.indexOf(emote) !== -1 ? EmoteAnimation[emote] : EmoteAnimation[`${emote}_${lines.length}LINE`]; if(dialogueType !== 'player') { const npcDialogueAction: NpcDialogueAction = { - npcId: npc as number, animation, lines, tag, type: 'NPC' + npcId: npc as number, animation, lines, tag, type: 'NPC', customName }; parsedDialogueTree.push(npcDialogueAction); } else { const playerDialogueAction: PlayerDialogueAction = { - player, animation, lines, tag, type: 'PLAYER' + player, animation, lines, tag, type: 'PLAYER', customName }; parsedDialogueTree.push(playerDialogueAction); @@ -427,6 +465,50 @@ async function runDialogueAction(player: Player, dialogueAction: string | Dialog player.outgoingPackets.updateWidgetString(widgetId, i, lines[i]); } } + } else if(dialogueAction.type === 'ITEM') { + // Dialogue with an item on the left. + + if(tag === undefined || dialogueAction.tag === tag) { + tag = undefined; + + const itemDialogueAction = dialogueAction as ItemDialogueAction; + const lines = itemDialogueAction.lines; + + if(lines.length > 5) { + throw new Error(`Too many lines for item dialogue! Dialogue has ${lines.length} lines but ` + + `the maximum is 4: ${JSON.stringify(lines)}`); + } + + widgetId = itemWidgetIds[lines.length - 1]; + let itemId: number; + + if (typeof itemDialogueAction.itemId === 'number') { + itemId = itemDialogueAction.itemId; + } else if (typeof itemDialogueAction.itemId === 'string') { + const itemDetails = findItem(itemDialogueAction.itemId); + if (!itemDetails) { + throw new Error(`The item ${itemDialogueAction.itemId} is not configured in the server!`); + } + itemId = itemDetails.gameId; + } else { + throw new Error(`Invalid item ID provided: ${itemDialogueAction.itemId}`); + } + + const model = filestore.configStore.itemStore.getItem(itemId)?.model2d; + + if (!model) { + throw new Error(`The model for item ${itemDialogueAction.itemId} was not found in the filestore!`); + } + + player.outgoingPackets.updateWidgetModel1(widgetId, 0, model.widgetModel); + player.outgoingPackets.setWidgetModelRotationAndZoom(widgetId, 0, + model.rotationY || 0, + model.rotationX || 0, + model.zoom / 2 || 0); + for(let i = 0; i < lines.length; i++) { + player.outgoingPackets.updateWidgetString(widgetId, i + 1, lines[i]); + } + } } else if(dialogueAction.type === 'TITLED') { // Text-only dialogue. @@ -500,14 +582,15 @@ async function runDialogueAction(player: Player, dialogueAction: string | Dialog const animation = actorDialogueAction.animation; if(dialogueAction.type === 'NPC') { + const name = actorDialogueAction.customName || filestore.configStore.npcStore.getNpc(npcId as number).name; widgetId = npcWidgetIds[lines.length - 1]; player.outgoingPackets.setWidgetNpcHead(widgetId, 0, npcId as number); - player.outgoingPackets.updateWidgetString(widgetId, 1, - filestore.configStore.npcStore.getNpc(npcId as number).name); + player.outgoingPackets.updateWidgetString(widgetId, 1, name); } else { + const name = actorDialogueAction.customName || player.username; widgetId = playerWidgetIds[lines.length - 1]; player.outgoingPackets.setWidgetPlayerHead(widgetId, 0); - player.outgoingPackets.updateWidgetString(widgetId, 1, player.username); + player.outgoingPackets.updateWidgetString(widgetId, 1, name); } player.outgoingPackets.playWidgetAnimation(widgetId, 0, animation); @@ -520,13 +603,14 @@ async function runDialogueAction(player: Player, dialogueAction: string | Dialog if(tag === undefined && widgetId) { const permanent = additionalOptions?.permanent || false; + const multi = additionalOptions?.multi == null ? false : additionalOptions?.multi; if(permanent) { player.interfaceState.openChatOverlayWidget(widgetId); } else { player.interfaceState.openWidget(widgetId, { slot: 'chatbox', - multi: false + multi }); const widgetClosedEvent = await player.interfaceState.widgetClosed('chatbox'); @@ -565,6 +649,7 @@ async function runParsedDialogue(player: Player, dialogueTree: ParsedDialogueTre export async function dialogue(participants: (Player | NpcParticipant)[], dialogueTree: DialogueTree, additionalOptions?: AdditionalOptions): Promise { const player = participants.find(p => p instanceof Player) as Player; + const multi = additionalOptions?.multi == null ? false : additionalOptions.multi; if(!player) { throw new Error('Player instance not provided to dialogue action.'); diff --git a/src/game-engine/world/actor/player/cutscenes.ts b/src/game-engine/world/actor/player/cutscenes.ts index 4646c888a..99c5b05b4 100644 --- a/src/game-engine/world/actor/player/cutscenes.ts +++ b/src/game-engine/world/actor/player/cutscenes.ts @@ -1,6 +1,14 @@ -import { Player } from '@engine/world/actor/player/player'; +import { defaultPlayerTabWidgets, Player } from '@engine/world/actor/player/player'; import { Position } from '@engine/world/position'; +import { tabIndex } from '@engine/world/actor/player/interface-state'; +import { MinimapState } from '@engine/config/minimap-state'; +// Cutscene widgets +export const cutsceneWidgets = [ + tabIndex['friends'], + tabIndex['ignores'], + tabIndex['logout'] +]; /** * Various camera options for cutscenes. @@ -18,12 +26,19 @@ export interface CameraOptions { lookAcceleration?: number; } +/** + * Cutscene options. + */ +export interface CutsceneOptions { + hideMinimap?: boolean; + hideTabs?: boolean; + setBusy?: boolean; +} /** * Controls a game cutscene for a specific player. */ export class Cutscene { - public readonly player: Player; private _cameraX: number; private _cameraY: number; @@ -35,22 +50,45 @@ export class Cutscene { private _lookHeight: number; private _lookMovementSpeed: number; private _lookAcceleration: number; + private cutsceneOptions: CutsceneOptions = { + hideTabs: false, + hideMinimap: false, + setBusy: true + } - public constructor(player: Player, options?: CameraOptions) { + public constructor(player: Player, cutsceneOptions: CutsceneOptions = {}, cameraOptions?: CameraOptions) { this.player = player; + this.setCutsceneOptions(cutsceneOptions); + + if (cameraOptions) { + this.setCamera(cameraOptions); + } + } + + setCutsceneOptions(cutsceneOptions: CutsceneOptions) { + this.cutsceneOptions = { ...this.cutsceneOptions, ...cutsceneOptions }; + const { hideMinimap, hideTabs, setBusy } = this.cutsceneOptions; + + if (hideMinimap) { + this.player.interfaceState.setMinimapState(MinimapState.BLACK); + } + + if (hideTabs) { + this.hideTabs(); + } - if(options) { - this.setCamera(options); + if (setBusy) { + this.player.busy = true; } } /** * Sets the cutscene camera to the specified options. - * @param options The camera options to use. + * @param cameraOptions The camera options to use. */ - public setCamera(options: CameraOptions): void { + public setCamera(cameraOptions: CameraOptions): void { const { cameraX, cameraY, cameraHeight, cameraMovementSpeed, cameraAcceleration, - lookX, lookY, lookHeight, lookMovementSpeed, lookAcceleration } = options; + lookX, lookY, lookHeight, lookMovementSpeed, lookAcceleration } = cameraOptions; if(cameraX && cameraY) { this.snapCameraTo(cameraX, cameraY, cameraHeight || 400, @@ -103,6 +141,19 @@ export class Cutscene { */ public endCutscene(): void { this.player.outgoingPackets.resetCamera(); + + if (this.cutsceneOptions.hideTabs) { + this.resetTabs(); + } + + if (this.cutsceneOptions.hideMinimap) { + this.player.interfaceState.setMinimapState(MinimapState.NORMAL); + } + + if (this.cutsceneOptions.setBusy) { + this.player.busy = false; + } + this.player.cutscene = null; } @@ -146,4 +197,20 @@ export class Cutscene { public get lookAcceleration(): number { return this._lookAcceleration; } + + private hideTabs() { + Object.keys(tabIndex).forEach(tab => { + if (cutsceneWidgets.indexOf(tabIndex[tab]) === -1) { + this.player.outgoingPackets.sendTabWidget(tabIndex[tab], null); + } + }); + } + + private resetTabs() { + defaultPlayerTabWidgets.forEach((widgetId: number, tabIndex: number) => { + if (widgetId !== -1) { + this.player.setSidebarWidget(tabIndex, widgetId); + } + }); + } } diff --git a/src/game-engine/world/actor/player/interface-state.ts b/src/game-engine/world/actor/player/interface-state.ts index 7faed6649..513974e69 100644 --- a/src/game-engine/world/actor/player/interface-state.ts +++ b/src/game-engine/world/actor/player/interface-state.ts @@ -2,6 +2,10 @@ import { Player } from '@engine/world/actor/player/player'; import { ItemContainer } from '@engine/world/items/item-container'; import { lastValueFrom, Subject } from 'rxjs'; import { filter, take } from 'rxjs/operators'; +import { widgets } from '@engine/config'; +import { animationIds } from '@engine/world/config/animation-ids'; +import { schedule } from '@engine/world/task'; +import { MinimapState } from '@engine/config/minimap-state'; export type TabType = 'combat' | 'skills' | 'quests' | 'inventory' | 'equipment' | 'prayers' | @@ -74,7 +78,6 @@ export interface WidgetClosedEvent { * Control's a Player's Game Interface state. */ export class InterfaceState { - public readonly tabs: { [key: string]: Widget | null }; public readonly widgetSlots: { [key: string]: Widget | null }; public readonly closed: Subject = new Subject(); @@ -109,12 +112,12 @@ export class InterfaceState { public openChatOverlayWidget(widgetId: number): void { this._chatOverlayWidget = widgetId; - this.player.outgoingPackets.showChatDialogue(widgetId); + this.player.outgoingPackets.showPermanentDialogueWidget(widgetId); } public closeChatOverlayWidget(): void { this._chatOverlayWidget = null; - this.player.outgoingPackets.showChatDialogue(-1); + this.player.outgoingPackets.showPermanentDialogueWidget(-1); } public openScreenOverlayWidget(widgetId: number): void { @@ -127,6 +130,35 @@ export class InterfaceState { this.player.outgoingPackets.showScreenOverlayWidget(-1); } + /** + * Fades out the screen and leaves it blank. Call fade in to return it to normal. + */ + public async fadeOutScreen(): Promise { + return new Promise(resolve => { + this.openWidget(widgets.fade, { slot: 'screen' }); + schedule(3).then(() => { + resolve(); + }); + }); + } + + /** + * Fades in the screen. Only works if fade out was called previously + */ + public async fadeInScreen(): Promise { + return new Promise(resolve => { + this.player.outgoingPackets.playWidgetAnimation(widgets.fade, 0, animationIds.fadeIn); + schedule(2).then(() => { + this.closeAllSlots(); + resolve(); + }); + }); + } + + public setMinimapState(minimapState: MinimapState) { + this.player.outgoingPackets.setMinimapState(minimapState); + } + public async widgetClosed(slot: GameInterfaceSlot): Promise { return await lastValueFrom(this.closed.asObservable().pipe( filter(event => event.widget.slot === slot)).pipe(take(1))); @@ -139,6 +171,11 @@ export class InterfaceState { return; } + // Permanent chatbox widgets must be closed like this, or else they show forever + if (widget.slot === 'chatbox' && widget.multi) { + this.closeChatOverlayWidget(); + } + this.closed.next({ widget, widgetId, data }); this.widgetSlots[widget.slot] = null; } @@ -245,7 +282,7 @@ export class InterfaceState { } else if(slot === 'chatbox') { if(multi) { // Dialogue Widget - packets.showChatDialogue(widgetId); + packets.showPermanentDialogueWidget(widgetId); } else { // Chatbox Widget packets.showChatboxWidget(widgetId); diff --git a/src/game-engine/world/actor/player/player.ts b/src/game-engine/world/actor/player/player.ts index 81639a236..bae4310a8 100644 --- a/src/game-engine/world/actor/player/player.ts +++ b/src/game-engine/world/actor/player/player.ts @@ -469,7 +469,7 @@ export class Player extends Actor { */ public getQuest(questId: string): PlayerQuest { let playerQuest = this.quests.find(quest => quest.questId === questId); - if(!playerQuest) { + if (!playerQuest) { playerQuest = new PlayerQuest(questId); this.quests.push(playerQuest); } @@ -491,15 +491,15 @@ export class Player extends Actor { } let playerQuest = this.quests.find(quest => quest.questId === questId); - if(!playerQuest) { + if (!playerQuest) { playerQuest = new PlayerQuest(questId); this.quests.push(playerQuest); } - if(playerQuest.progress === 0 && !playerQuest.complete) { + if (typeof progress === 'number' && progress > 0) { playerQuest.progress = progress; this.modifyWidget(widgets.questTab, { childId: questData.questTabId, textColor: colors.yellow }); - } else if(!playerQuest.complete && progress === 'complete') { + } else if (progress === 'complete') { playerQuest.complete = true; playerQuest.progress = 'complete'; this.outgoingPackets.updateClientConfig(widgetScripts.questPoints, questData.points + this.getQuestPoints()); @@ -521,17 +521,22 @@ export class Player extends Actor { } if(questData.onComplete.questCompleteWidget.itemId) { - this.outgoingPackets.updateWidgetModel1(widgets.questReward, 3, - filestore.configStore.itemStore.getItem(questData.onComplete.questCompleteWidget.itemId)?.model2d?.widgetModel); + const questCompleteItem = filestore.configStore.itemStore.getItem(questData.onComplete.questCompleteWidget.itemId); + if (questCompleteItem) { + this.outgoingPackets.updateWidgetModel1(widgets.questReward, 3, questCompleteItem.model2d?.widgetModel); + this.outgoingPackets.setWidgetModelRotationAndZoom(widgets.questReward, 3, + questCompleteItem.model2d?.rotationY || 0, + questCompleteItem.model2d?.rotationX || 0, + questCompleteItem.model2d?.zoom / 2 || 0); + } } else if(questData.onComplete.questCompleteWidget.modelId) { this.outgoingPackets.updateWidgetModel1(widgets.questReward, 3, questData.onComplete.questCompleteWidget.modelId); + this.outgoingPackets.setWidgetModelRotationAndZoom(widgets.questReward, 3, + questData.onComplete.questCompleteWidget.modelRotationX || 0, + questData.onComplete.questCompleteWidget.modelRotationY || 0, + questData.onComplete.questCompleteWidget.modelZoom || 0); } - this.outgoingPackets.setWidgetModelRotationAndZoom(widgets.questReward, 3, - questData.onComplete.questCompleteWidget.modelRotationX || 0, - questData.onComplete.questCompleteWidget.modelRotationY || 0, - questData.onComplete.questCompleteWidget.modelZoom || 0); - this.interfaceState.openWidget(widgets.questReward, { slot: 'screen', multi: false @@ -542,6 +547,8 @@ export class Player extends Actor { if(questData.onComplete.giveRewards) { questData.onComplete.giveRewards(this); } + } else { + throw new Error('Unhandled progress value: ' + progress); } } @@ -1004,6 +1011,7 @@ export class Player extends Actor { /** * Returns the morphed NPC details for a specific player based on his client settings * @param originalNpc + * @returns the morphed NPC, or null if there is none */ public getMorphedNpcDetails(originalNpc: Npc) { if (!originalNpc.childrenIds) { diff --git a/src/game-engine/world/index.ts b/src/game-engine/world/index.ts index 1bf0aa054..b7e6b128b 100644 --- a/src/game-engine/world/index.ts +++ b/src/game-engine/world/index.ts @@ -379,6 +379,7 @@ export class World { position, movementRadius, face), instanceId); await this.registerNpc(npc); + await schedule(1); return npc; } diff --git a/src/plugins/dialogue/dialogue-option.plugin.ts b/src/plugins/dialogue/dialogue-option.plugin.ts index 4fe989d1c..15ba4d3ec 100644 --- a/src/plugins/dialogue/dialogue-option.plugin.ts +++ b/src/plugins/dialogue/dialogue-option.plugin.ts @@ -1,10 +1,18 @@ import { widgetInteractionActionHandler } from '@engine/world/action/widget-interaction.action'; +import { + continuableTextWidgetIds, + itemWidgetIds, + npcWidgetIds, + optionWidgetIds, + playerWidgetIds +} from '@engine/world/actor/dialogue'; const dialogueIds = [ - 64, 65, 66, 67, 241, - 242, 243, 244, 228, 230, - 232, 234, - 210, 211, 212, 213, 214, + ...playerWidgetIds, + ...npcWidgetIds, + ...optionWidgetIds, + ...continuableTextWidgetIds, + ...itemWidgetIds ]; /** diff --git a/src/plugins/objects/ladders/staircase.plugin.ts b/src/plugins/objects/ladders/staircase.plugin.ts new file mode 100644 index 000000000..6d35bac9b --- /dev/null +++ b/src/plugins/objects/ladders/staircase.plugin.ts @@ -0,0 +1,71 @@ +import { objectInteractionActionHandler } from '@engine/world/action/object-interaction.action'; +import { dialogueAction } from '@engine/world/actor/player/dialogue-action'; +import { World } from '@engine/world'; +import { Position } from '@engine/world/position'; + +const planes = { min: 0, max: 3 }; +const validate: (level: number) => boolean = (level) => { + return planes.min <= level && level <= planes.max; +}; //TODO: prevent no-clipping. + +export const action: objectInteractionActionHandler = (details) => { + const { player, option, object } = details; + + const up = option === 'climb-up'; + const { position } = player; + const level = position.level + (up ? 1 : -1); + + const direction = object.orientation; + let newX = position.x; + let newY = position.y; + const tilesToMove = 4; + + switch (direction) { + case 0: + if (up) { + newY += tilesToMove; + } else { + newY -= tilesToMove; + } + break; + case 1: + if (up) { + newX += tilesToMove; + } else { + newX -= tilesToMove; + } + break; + case 2: + if (up) { + newY -= tilesToMove; + } else { + newY += tilesToMove; + } + break; + case 3: + if (up) { + newX -= tilesToMove; + } else { + newX += tilesToMove; + } + break; + } + + if (!validate(level)) return; + setTimeout(() => { + details.player.teleport(new Position(newX, newY, level)); + }, World.TICK_LENGTH); +}; + +export default { + pluginId: 'rs:staircases', + hooks: [ + { + type: 'object_interaction', + objectIds: [ 1722, 1723 ], + options: [ 'climb-up', 'climb-down' ], + walkTo: true, + handler: action + } + ] +}; diff --git a/src/plugins/quests/cooks-assistant-quest.plugin.ts b/src/plugins/quests/cooks-assistant-quest.plugin.ts index ec01cef88..787603723 100644 --- a/src/plugins/quests/cooks-assistant-quest.plugin.ts +++ b/src/plugins/quests/cooks-assistant-quest.plugin.ts @@ -262,8 +262,8 @@ export default { rewardText: [ '300 Cooking XP' ], itemId: 1891, modelZoom: 240, - modelRotationX: 180, - modelRotationY: 180 + modelRotationY: 180, + modelRotationX: 180 }, giveRewards: (player: Player): void => player.skills.cooking.addExp(300) diff --git a/src/plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy-quest.plugin.ts b/src/plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy-quest.plugin.ts index b839815b4..89c6a53cd 100644 --- a/src/plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy-quest.plugin.ts +++ b/src/plugins/quests/goblin-diplomacy-tutorial/goblin-diplomacy-quest.plugin.ts @@ -225,8 +225,8 @@ export default { rewardText: [ 'A training sword & shield' ], itemId: 9703, modelZoom: 200, - modelRotationX: 0, - modelRotationY: 180 + modelRotationY: 0, + modelRotationX: 180 } } }) diff --git a/src/plugins/quests/quest-journal.plugin.ts b/src/plugins/quests/quest-journal.plugin.ts index 358be7443..82e7da5f6 100644 --- a/src/plugins/quests/quest-journal.plugin.ts +++ b/src/plugins/quests/quest-journal.plugin.ts @@ -1,9 +1,10 @@ import { buttonActionHandler } from '@engine/world/action/button.action'; import { wrapText } from '@engine/util/strings'; -import { questMap } from '@engine/game-server'; +import { filestore, questMap } from '@engine/game-server'; import { widgets } from '@engine/config'; import { Quest } from '@engine/world/actor/player/quest'; import { QuestKey } from '@engine/config/quest-config'; +import { ParentWidget, TextWidget } from '@runejs/filestore'; export const handler: buttonActionHandler = async ({ player, buttonId }) => { @@ -40,7 +41,7 @@ export const handler: buttonActionHandler = async ({ player, buttonId }) => { } } - const color = 128; + const color = '000080'; let text: string; if(typeof journalHandler === 'function') { @@ -49,9 +50,15 @@ export const handler: buttonActionHandler = async ({ player, buttonId }) => { text = journalHandler; } + // Fetch the quest diary widget + const widget = (filestore.widgetStore.decodeWidget(275) as ParentWidget).children[3] as TextWidget; + if (!widget) { + throw new Error('Error fetching the quest widget!'); + } + let lines; if(text) { - lines = wrapText(text as string, 395); + lines = wrapText(text as string, widget.width, widget.fontId); } else { lines = [ 'Invalid Quest Stage' ]; } diff --git a/src/plugins/quests/romeo-and-juliet/apothecary-dialogue.ts b/src/plugins/quests/romeo-and-juliet/apothecary-dialogue.ts new file mode 100644 index 000000000..c2b5ee450 --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/apothecary-dialogue.ts @@ -0,0 +1,46 @@ +import { dialogue, Emote, goto } from '@engine/world/actor/dialogue'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; + +export const apothecaryOptions = (player: Player) => { + return (options, tag_OPTIONS) => { + const firstOption = [`Have you got any decent gossip to share?`, [ + player => [Emote.POMPOUS, `Have you got any decent gossip to share?`], + apothecary => [Emote.GENERIC, `Well I hear young Romeo's having a little woman trouble but other than that all's quiet on the eastern front. Can I do something for you?`], + goto('tag_OPTIONS') + ]]; + + const restOptions = [ + `Do you know a potion to make hair fall out?`, [ + player => [Emote.HAPPY, `Do you know a potion to make hair fall out?`], + apothecary => [Emote.HAPPY, `I do indeed. I gave it to my mother. That's why I now live alone.`], + apothecary => [Emote.GENERIC, `But can I do something for you?`], + goto('tag_OPTIONS') + ], + `Have you got any good potions to give away?`, [ + player => [Emote.HAPPY, `Have you got any good potions to give away?`], + apothecary => [Emote.SAD, `Sorry, charity is not my strong point. Do you need anything else?`], + goto('tag_OPTIONS') + ], + `No thanks.`, [ + player => [Emote.VERY_SAD, `No thanks.`] + ] + ]; + + return [...firstOption, ...restOptions]; + }; +}; + +export const apothecaryDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'apothecary' }]; + await dialogue(participants, [ + apothecary => [Emote.GENERIC, `I am the Apothecary. I brew potions. Do you need anything specific?`], + apothecaryOptions(player) + ]); + }, + 1: 0, + 2: 0, + 3: 0 +}; diff --git a/src/plugins/quests/romeo-and-juliet/draul-leptoc-dialogue.ts b/src/plugins/quests/romeo-and-juliet/draul-leptoc-dialogue.ts new file mode 100644 index 000000000..4f5ce0fd3 --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/draul-leptoc-dialogue.ts @@ -0,0 +1,88 @@ +import { dialogue, Emote } from '@engine/world/actor/dialogue'; +import { questItems } from './romeo-and-juliet-quest.plugin'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; + +export const draulDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'draul' }]; + await dialogue(participants, [ + draul => [Emote.ANGRY, `What are you doing in my house...why the impertinence...the sheer cheek...how dare you violate my personal lodgings....`], + player => [Emote.GENERIC, `I..I was just looking around....`], + draul => [Emote.ANGRY, `Well get out! Get out....this is my house....and don't go near my daughter Juliet...she's grounded in her room to keep her away from that good for nothing Romeo.`], + player => [Emote.GENERIC, `Yes....sir....`] + ]); + }, + + 1: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'draul' }]; + await dialogue(participants, [ + draul => [Emote.ANGRY, `What are you doing here? Snooping around... `], + options => [ + `I've come to see Juliet on Romeo's behalf.`, [ + player => [Emote.GENERIC, `I've come to see Juliet on Romeo's behalf.`], + draul => [Emote.ANGRY, `What...what...Romeo! Why that good for nothing swine...he's always trying to get the affections of my daughter..that soppy, half brained nincompoop won't ever have the heart of my daughter.`], + draul => [Emote.ANGRY, `She deserves someone of character, wit and repose.`], + player => [Emote.WONDERING, `What's so wrong about Romeo?`], + draul => [Emote.ANGRY, `Wrong! What's wrong with him...have you actually talked to him? He's nothing but a dim witted upperclass twit, totally useless.`], + draul => [Emote.ANGRY, `If he threw a stone at the ground, he'd probably miss! He's totally invisible when it's raining because he's so wet!`], + draul => [Emote.ANGRY, `If you started with what's right with him, you'd have much less to consider!`], + player => [Emote.WONDERING, `Well, I admit, he's probably not the sharpest knife in the cutlery draw...`], + // TODO next 2 lines are shown as 1 in the OSRS wiki transcript + draul => [Emote.ANGRY, `Sharp? I've seen keener wit in root vegetables. Anyway, stop changing the subject.`], + draul => [Emote.ANGRY, `Get out of here and don't think you can sneak up those stairs to see Juliet, because I'll catch you and then you'll be for it!`], + player => [Emote.WONDERING, `That seems a bit harsh....`], + draul => [Emote.ANGRY, `Harsh but fair I think you'll find...now get OUT!`] + ], + `I've just come to have a chat with Juliet.`, [ + player => [Emote.GENERIC, `I've just come to have a chat with Juliet.`], + draul => [Emote.ANGRY, `What on earth about? I hope you're not in cahoots with that good for nothing Romeo!`], + player => [Emote.WONDERING, `Err..no of course not....why would I be?`], + // TODO next 2 lines are shown as 1 in the OSRS wiki transcript + draul => [Emote.SKEPTICAL, `He's been trying to wooo my daughter for an age. Up until now she's had the good sense to just ignore him.`], + draul => [Emote.SKEPTICAL, `I just don't know what's gotten into her recently so that she would give him the time of day.`], + player => [Emote.SHOCKED, `Well, love is mysterious! Perhaps one day someone may even learn to love you!`], + draul => [Emote.ANGRY, `What! Someone may fall in love with me...what are you trying to insinuate?`], + player => [Emote.WONDERING, `Err...Nothing....I guess I'd better be going now...`] + ], + `Oh...just looking around...`, [ + player => [Emote.GENERIC, `Oh...just looking around...`], + draul => [Emote.ANGRY, `Just looking around! This is MY house! You might have at least 'ASKED' to view my considerably well appointed abode...but no, you've just burst in with all the elegance of a Troll at a tea party.`], + player => [Emote.GENERIC, `I can see that you're busy ranting so I'll just nip off and investigate a bit.`] + ] + ], + ]); + }, + + 2: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'draul' }]; + await dialogue(participants, [ + draul => [Emote.ANGRY, `What are you doing in my house? Up to no good I shouldn't wonder!`], + player => [Emote.GENERIC, `Just a small chore for Juliet, you do have a lovely daughter in her sir.`], + draul => [Emote.HAPPY, `Oh...why, thank you...I've always tried to my best...`], + draul => [Emote.ANGRY, ` ...Hang on! Enough of that smiley talk. I have a daughter and I know what she's like. Don't even think of carrying on anything behind my back, I have the eyes of a hawk, nothing gets past me!`] + ]); + + if (!player.hasItemInInventory(questItems.julietLetter.gameId)) { + return; + } + + await dialogue(participants, [ + item => [questItems.julietLetter.gameId, `Sir Draul notices the message!`], + draul => [Emote.ANGRY, `Hey! What's that in your hands...looks like a message to me...with Juliet's barely legible scrawl on it...`], + player => [Emote.SHOCKED, `Yes, yes, that's probably why I can't read it!`], + player => [Emote.SHOCKED, `Sorry, I mean, that's right sir. I'm just popping to the shops to get some groceries for Juliet.`], + player => [Emote.WONDERING, `Right, have to be off now...thanks...`], + draul => [Emote.ANGRY, `Groceries!`], + draul => [Emote.ANGRY, `Groceries!...at a time like this, does that girl know what she's putting me through!`] + ]); + }, + + 3: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'draul' }]; + await dialogue(participants, [ + draul => [Emote.ANGRY, `Do you live here? If so, how's about a couple of hundred gold towards the rent eh? Pay your share I say...you don't want to be like that freeloading Romeo!`], + ]); + }, +}; diff --git a/src/plugins/quests/romeo-and-juliet/father-lawrence-dialogue.ts b/src/plugins/quests/romeo-and-juliet/father-lawrence-dialogue.ts new file mode 100644 index 000000000..f000c9700 --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/father-lawrence-dialogue.ts @@ -0,0 +1,72 @@ +import { dialogue, Emote, goto } from '@engine/world/actor/dialogue'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; +import { Cutscene } from '@engine/world/actor/player/cutscenes'; + +export const lawrenceOptions = () => { + return (options, tag_OPTIONS) => [ + `I am always looking for a quest.`, [ + player => [Emote.HAPPY, `I am always looking for a quest.`], + lawrence => [Emote.GENERIC, `Well, I see poor Romeo wandering around the square. I think he may need help.`], + lawrence => [Emote.SAD, `I was helping him and Juliet to meet, but it became impossible.`], + lawrence => [Emote.HAPPY, `I am sure he can use some help.`], + goto('tag_OPTIONS') + ], + `No, I prefer just to kill things.`, [ + player => [Emote.HAPPY, `No, I prefer just to kill things.`], + lawrence => [Emote.HAPPY, `That's a fine career in these lands. There is more that needs killing every day.`], + goto('tag_OPTIONS') + ], + `Can you recommend a good bar?`, [ + player => [Emote.GENERIC, `Can you recommend a good bar?`], + lawrence => [Emote.ANGRY, `Drinking will be the death of you.`], + lawrence => [Emote.GENERIC, `But the Blue Moon in the city is cheap enough.`], + lawrence => [Emote.HAPPY, `And providing you buy one drink an hour they let you stay all night.`], + goto('tag_OPTIONS') + ], + `Ok, thanks`, [ + player => [Emote.GENERIC, `Ok, thanks`] + ] + ]; +}; + +export const lawrenceDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'lawrence' }]; + await dialogue(participants, [ + lawrence => [Emote.GENERIC, `Hello adventurer, do you seek a quest?`], + lawrenceOptions() + ]); + }, + 1: 0, + 2: 0, + + 3: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'lawrence' }]; + await dialogue(participants, [ + lawrence => [Emote.HAPPY, `''...and let Saradomin light the way for you... '' Urgh!`], + lawrence => [Emote.ANGRY, `Can't you see that I'm in the middle of a Sermon?!`], + player => [Emote.ANGRY, `But Romeo sent me!`], + lawrence => [Emote.ANGRY, `But I'm busy delivering a sermon to my congregation!`], + ]); + + player.cutscene = new Cutscene(player, { hideTabs: false, hideMinimap: false }); + player.cutscene.snapCameraTo(3254, 3486, 330); + player.cutscene.lookAt(3255, 3479, 300); + + const congregation = [player, { npc: 'rs:jeremy_clerksin', key: 'congregation' }] + await dialogue(congregation, [ + congregation => [Emote.SLEEPING, `Zzzzzzzzz`, `Congregation`], + player => [Emote.VERY_SAD, `Yes, well, it certainly seems like you have a captive audience!`] + ]); + + player.cutscene.endCutscene(); + + await dialogue(participants, [ + lawrence => [Emote.WONDERING, `Ok, ok...what do you want so I can get rid of you and continue with my sermon?`] + ]); + + // TODO + }, +}; diff --git a/src/plugins/quests/romeo-and-juliet/juliet-dialogue.ts b/src/plugins/quests/romeo-and-juliet/juliet-dialogue.ts new file mode 100644 index 000000000..934d4689b --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/juliet-dialogue.ts @@ -0,0 +1,97 @@ +import { dialogue, Emote } from '@engine/world/actor/dialogue'; +import { questItems, questKey } from './romeo-and-juliet-quest.plugin'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; +import { findNpc } from '@engine/config'; +import { getVarbitConfigId } from '@engine/util/varbits'; + +export const calculateJulietVisibility = (player: Player) => { + const julietParentNpc = findNpc('rs:juliet'); + const configId = getVarbitConfigId(julietParentNpc.varbitId); + + const hideJulietForPlayer = player.getQuest(questKey).progress === 4 ? 1 : 0; // TODO fix this value + player.outgoingPackets.updateClientConfig(configId, hideJulietForPlayer); +} + +export const julietDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'juliet' }]; + await dialogue(participants, [ + juliet => [Emote.SAD, `Romeo, Romeo, wherefore art thou Romeo?`], + text => `She seems to be lost in thought.` + ]); + }, + + 1: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'juliet' }]; + await dialogue(participants, [ + player => [Emote.GENERIC, `Juliet, I come from Romeo. He begs me to tell you that he cares still.`], + juliet => [Emote.HAPPY, `Oh how my heart soars to hear this news! Please take this message to him with great haste.`], + player => [Emote.GENERIC, `Well, I hope it's good news...he was quite upset when I left him.`], + juliet => [Emote.POMPOUS, `He's quite often upset...the poor sensitive soul. But I don't think he's going to take this news very well, however, all is not lost.`], + juliet => [Emote.HAPPY, `Everything is explained in the letter, would you be so kind and deliver it to him please?`], + player => [Emote.HAPPY, `Certainly, I'll do so straight away.`], + juliet => [Emote.HAPPY, `Many thanks! Oh, I'm so very grateful. You may be our only hope.`] + ]); + + const giveLetterSuccess = player.giveItem('rs:juliet_letter'); + if (giveLetterSuccess) { + player.setQuestProgress('rs:romeo_and_juliet', 2); + await dialogue(participants, [ + item => [questItems.julietLetter.gameId, `Juliet gives you a message.`] + ]); + } else { + await dialogue(participants, [ + text => `You don't have enough space in your inventory!` + ]); + } + }, + + 2: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'juliet' }]; + + const hasLetter = player.hasItemInInventory(questItems.julietLetter.gameId) || player.hasItemInBank(questItems.julietLetter.gameId); + if (hasLetter) { + await dialogue(participants, [ + player => [Emote.HAPPY, `Hello Juliet!`], + juliet => [Emote.HAPPY, `Hello there...have you delivered the message to Romeo yet? What news do you have from my loved one?`], + player => [Emote.SKEPTICAL, `Oh, sorry, I've not had chance to deliver it yet!`], + juliet => [Emote.SAD, `Oh, that's a shame. I've been waiting so patiently to hear some word from him.`] + ]); + } else { + await dialogue(participants, [ + player => [Emote.HAPPY, `Hello Juliet!`], + juliet => [Emote.HAPPY, `Hello there...have you delivered the message to Romeo yet? What news do you have from my loved one?`], + player => [Emote.SKEPTICAL, `Hmmm, that's the thing about messages...they're so easy to misplace...`], + juliet => [Emote.SAD, `How could you lose that message? It was incredibly important...and it took me an age to write! I used joined up writing and everything!`], + juliet => [Emote.SAD, `Please, take this new message to him, and please don't lose it.`] + ]); + + const giveLetterSuccess = player.giveItem('rs:juliet_letter'); + if (giveLetterSuccess) { + await dialogue(participants, [ + item => [questItems.julietLetter.gameId, `Juliet gives you another message.`] + ]); + } else { + await dialogue(participants, [ + text => `You don't have enough space in your inventory!` + ]); + } + } + }, + + 3: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'juliet' }]; + await dialogue(participants, [ + player => [Emote.HAPPY, `Hi Juliet, I have passed your message on to Romeo..he's scared half out of his wits at the news that your father wants to kill him.`], + juliet => [Emote.SAD, `Yes, unfortunately my father is quite the hunter, you may have seen some the animal head trophies on the wall. And it would be so awful to see Romeo's head up there with them!`], + player => [Emote.SAD, `I know what you mean...`], + player => [Emote.POMPOUS, `...his hair colour will clash terribly with the rest of the decoration.`], + juliet => [Emote.ANGRY, `That's not what I was suggesting at all...`], + player => [Emote.HAPPY, `I know, I know...I was just kidding.`], + player => [Emote.HAPPY, `Anyway, don't worry because I'm on the case. I'm going to get some help from Father Lawrence.`], + juliet => [Emote.HAPPY, `Oh yes, I'm sure that Father Lawrence will come up with a solution. I hope you find him soon.`], + ]); + }, +}; diff --git a/src/plugins/quests/romeo-and-juliet/phillipa-dialogue.ts b/src/plugins/quests/romeo-and-juliet/phillipa-dialogue.ts new file mode 100644 index 000000000..b2dab61b2 --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/phillipa-dialogue.ts @@ -0,0 +1,38 @@ +import { dialogue, Emote } from '@engine/world/actor/dialogue'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; + +export const phillipaDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'phillipa' }]; + await dialogue(participants, [ + player => [Emote.GENERIC, `Hello, who are you?`], + phillipa => [Emote.HAPPY, ` Hi, I'm Phillipa, Juliet's cousin. I like to keep an eye on her, make sure that dashing young Romeo doesn't just steal her away under our plain old noses!`], + phillipa => [Emote.HAPPY, `He'd do it, you know - he's ever so dashing, and cavalier, in a wet blanket sort of way.`], + player => [Emote.GENERIC, `Romeo? Where would I find him then?`], + phillipa => [Emote.HAPPY, `Well, that's a good question! Who knows where his head's at most of the time? In the clouds, most likely!`], + phillipa => [Emote.HAPPY, `But he's probably chasing the ladies who frequent Varrock market. He does like a bit of kiss chase, so I've heard!`], + ]); + }, + + 1: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'phillipa' }]; + await dialogue(participants, [ + player => [Emote.GENERIC, `Hello`], + phillipa => [Emote.HAPPY, `Hi, I'm Phillipa! Juliet's cousin? I like to keep an eye on her, make sure that dashing young Romeo doesn't just steal away from here under our plain old noses!`], + phillipa => [Emote.GENERIC, `He'd do it you know... he's ever so dashing, and cavalier, in a wet blanket sort of way.`] + ]); + }, + + 2: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'phillipa' }]; + await dialogue(participants, [ + phillipa => [Emote.HAPPY, `Oh, hello. Juliet has told me what you're doing for her and Romeo, and I have to say I'm very grateful to you. Juliet deserves a bit of happiness in her life.`], + phillipa => [Emote.HAPPY, `And I'm sure Romeo is just the sort of jester to make her laugh out loud - hysterically you might say.`], + phillipa => [Emote.HAPPY, `He always brings a tear to my eyes - tears of happiness at his foolish antics!`], + player => [Emote.GENERIC, `Oh, thanks. I like to do my cupid bit.`] + ]); + }, + 3: 2 +} diff --git a/src/plugins/quests/romeo-and-juliet/romeo-and-juliet-quest.plugin.ts b/src/plugins/quests/romeo-and-juliet/romeo-and-juliet-quest.plugin.ts new file mode 100644 index 000000000..875fe40ea --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/romeo-and-juliet-quest.plugin.ts @@ -0,0 +1,100 @@ +import { Quest } from '@engine/world/actor/player/quest'; +import { ContentPlugin } from '@engine/plugins/content-plugin'; +import { NpcInteractionActionHook } from '@engine/world/action/npc-interaction.action'; +import { findItem } from '@engine/config'; +import { questDialogueActionFactory } from '@engine/config/quest-config'; +import { playerInitActionHandler, PlayerInitActionHook } from '@engine/world/action/player-init.action'; + +// Dialogues +import { phillipaDialogueHandler } from './phillipa-dialogue'; +import { calculateJulietVisibility, julietDialogueHandler } from './juliet-dialogue'; +import { romeoDialogueHandler } from './romeo-dialogue'; +import { draulDialogueHandler } from './draul-leptoc-dialogue'; +import { lawrenceDialogueHandler } from './father-lawrence-dialogue'; +import { apothecaryDialogueHandler } from './apothecary-dialogue'; + +const journalHandler = { + 0: `I can start this quest by speaking to Romeo in + Varrock central square by the fountain.`, + + 1: `I have agreed to find Juliet for Romeo and tell her how he feels. For some reason he can't just do this by himself. + I should go and speak to Juliet. I can find her west of Varrock.`, + + 2: `I have agreed to find Juliet for Romeo and tell her how he feels. For some reason he can't just do this himself. + I found Juliet on the Western edge of Varrock, and told her about Romeo. She gave me a message to take back. + I should take the message from Juliet to Romeo in Varrock central square.`, + + 3: `I have agreed to find Juliet for Romeo and tell her how he feels. For some reason he can't just do this himself. + I found Juliet on the Western edge of Varrock, and told her about Romeo. She gave me a message to take back. + I delivered the message to Romeo, and he was sad to hear that Juliet's father opposed their marriage. However, he said that Father Lawrence, might be able to overcome this. + I should find Father Lawrence and see how we can help. I can find him in his church in the north-east of Varrock.` +}; + +export const questItems = { + julietLetter: findItem('rs:juliet_letter') +} + +export const questKey = 'rs:romeo_and_juliet'; + +const playerInitHook: playerInitActionHandler = details => { + calculateJulietVisibility(details.player); +}; + +export default { + pluginId: questKey, + quests: [ + new Quest({ + id: questKey, + questTabId: 37, + name: `Romeo & Juliet`, + points: 5, + journalHandler, + onComplete: { + questCompleteWidget: { + rewardText: [], + itemId: 756 + } + } + }) + ], + hooks: [{ + type: 'player_init', + handler: playerInitHook + }, { + type: 'npc_interaction', + npcs: 'rs:romeo', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, romeoDialogueHandler) + }, { + type: 'npc_interaction', + npcs: 'rs:juliet:visible', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, julietDialogueHandler) + }, { + type: 'npc_interaction', + npcs: 'rs:draul_leptoc', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, draulDialogueHandler) + }, { + type: 'npc_interaction', + npcs: 'rs:phillipa', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, phillipaDialogueHandler) + }, { + type: 'npc_interaction', + npcs: 'rs:father_lawrence', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, lawrenceDialogueHandler) + }, { + type: 'npc_interaction', + npcs: 'rs:apothecary', + options: 'talk-to', + walkTo: true, + handler: questDialogueActionFactory(questKey, apothecaryDialogueHandler) + }] +}; diff --git a/src/plugins/quests/romeo-and-juliet/romeo-dialogue.ts b/src/plugins/quests/romeo-and-juliet/romeo-dialogue.ts new file mode 100644 index 000000000..c13041085 --- /dev/null +++ b/src/plugins/quests/romeo-and-juliet/romeo-dialogue.ts @@ -0,0 +1,385 @@ +import { randomBetween } from '@engine/util/num'; +import { dialogue, Emote, execute, goto } from '@engine/world/actor/dialogue'; +import { Position } from '@engine/world/position'; +import { WorldInstance } from '@engine/world/instances'; +import uuidv4 from 'uuid/v4'; +import { QuestDialogueHandler } from '@engine/config/quest-config'; +import { Player } from '@engine/world/actor/player/player'; +import { Npc } from '@engine/world/actor/npc/npc'; +import { Cutscene } from '@engine/world/actor/player/cutscenes'; +import { world } from '@engine/game-server'; +import { schedule } from '@engine/world/task'; +import { questItems, questKey } from './romeo-and-juliet-quest.plugin'; + +const findingFatherLawrence = () => { + return (options, tag_FATHER_LAWRENCE) => [ + `How are you?`, [ + player => [Emote.POMPOUS, `How are you?`], + romeo => [Emote.SAD, `Not so good my friend...I miss Judi..., Junie..., Joopie...`], + player => [Emote.POMPOUS, `Juliet?`], + romeo => [Emote.SKEPTICAL, `Juliet! I miss Juliet, terribly!`], + player => [Emote.SKEPTICAL, `Hmmm, so I see!`], + goto('tag_FATHER_LAWRENCE') + ], + `Where can I find Father Lawrence?`, [ + player => [Emote.POMPOUS, `Where can I find Father Lawrence?`], + romeo => [Emote.HAPPY, `Lather Fawrence! Oh he's...`], + romeo => [Emote.SKEPTICAL, `You know he's not my 'real' Father don't you?`], + player => [Emote.VERY_SAD, `I think I suspected that he wasn't.`], + romeo => [Emote.HAPPY, `Well anyway...he tells these song, loring bermons...and keeps these here Carrockian vitizens snoring in his church to the East North.`], + goto('tag_FATHER_LAWRENCE') + ], + `Have you heard anything from Juliet?`, [ + player => [Emote.POMPOUS, `Have you heard anything from Juliet?`], + romeo => [Emote.SAD, `Sadly not my friend! And what's worse, her Father has threatened to kill me if he sees me. I mean, that seems a bit harsh!`], + player => [Emote.POMPOUS, `Well, I shouldn't worry too much...you can always run away if you see him...`], + romeo => [Emote.SAD, `I just wish I could remember what he looks like! I live in fear of every man I see!`], + goto('tag_FATHER_LAWRENCE') + ], + `Ok, thanks.`, [ + player => [Emote.GENERIC, `Ok, thanks.`] + ] + ]; +} + +const moreInfo = () => { + return (options, tag_MORE_INFO) => [ + `Where can I find Juliet?`, [ + player => [Emote.WONDERING, `Where can I find Juliet?`], + romeo => [Emote.WONDERING, `Why do you ask?`], + player => [Emote.ANGRY, `So that I can try and find her for you!`], + romeo => [Emote.WONDERING, `Ah yes....quite right. Hmmm, let me think now.`], + romeo => [Emote.WONDERING, `She may still be locked away at her Father's house on the sest vide of Warrock.`], + romeo => [Emote.HAPPY, `Oh, I remember how she loved it when I would sing up to her balcony! She would reward me with her own personal items...`], + player => [Emote.WONDERING, `What, she just gave you her stuff?`], + romeo => [Emote.GENERIC, `Well, not exactly give...more like 'throw with considerable force'...she's always a kidder that Juliet!`], + goto('tag_MORE_INFO') + ], + `Is there anything else you can tell me about Juliet?`, [ + player => [Emote.WONDERING, `Is there anything else you can tell me about Juliet?`], + romeo => [Emote.HAPPY, `Oh, there is so much to tell...she is my true love, we intend to spend together forever...I can tell you so much about her..`], + player => [Emote.GENERIC, `Great!`], + romeo => [Emote.WONDERING, `Ermmm.....`], + romeo => [Emote.WONDERING, `So much can I tell you...`], + player => [Emote.HAPPY, `Yes..`], + romeo => [Emote.WONDERING, `So much to tell...why, where do I start!`], + player => [Emote.GENERIC, `Yes..yes! Please go on...don't let me interrupt...`], + romeo => [Emote.WONDERING, `Ermmm.....`], + romeo => [Emote.WONDERING, `...`], + player => [Emote.WONDERING, `You can't remember can you?`], + romeo => [Emote.SAD, `Not a thing sorry....`], + goto('tag_MORE_INFO') + ], + `Ok, thanks.`, [ + player => [Emote.GENERIC, `Ok, thanks.`] + ] + ]; +}; + +export const romeoDialogueHandler: QuestDialogueHandler = { + 0: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'romeo' }]; + + // Romeo starts with a random line + const randomDialog = randomBetween(0, 5); + switch (randomDialog) { + case 0: + await dialogue(participants, [ + romeo => [Emote.SAD, `Blub! Blub...where is my Juliet? Have you seen her?`] + ]); + break; + + case 1: + await dialogue(participants, [ + romeo => [Emote.SAD, `Looking for a blonde girl, goes by the name of Juliet..quite pretty...haven't seen her have you?`] + ]); + break; + + case 2: + await dialogue(participants, [ + romeo => [Emote.SAD, `Juliet, Juliet, wherefore art thou Juliet? Have you seen my Juliet?`] + ]); + break; + + case 3: + await dialogue(participants, [ + romeo => [Emote.SAD, `Oh woe is me that I cannot find my Juliet! You haven't seen Juliet have you?`] + ]); + break; + + case 4: + await dialogue(participants, [ + romeo => [Emote.SAD, `Sadness surrounds me now that Juliet's father forbids us to meet. Have you seen my Juliet?`] + ]); + break; + + case 5: + await dialogue(participants, [ + romeo => [Emote.SAD, `What is to become of me and my darling Juliet, I cannot find her anywhere, have you seen her?`] + ]); + break; + } + + await dialogue(participants, [ + options => [ + `Yes, I have seen her actually!`, [ + player => [Emote.HAPPY, `Yes, I have seen her actually!`], + player => [Emote.SKEPTICAL, `At least, I think it was her... Blonde? A bit stressed?`], + romeo => [Emote.LAUGH, `Golly...yes, yes...you make her sound very interesting!`], + romeo => [Emote.SKEPTICAL, `And I'll bet she's a bit of a fox!`], + player => [Emote.SKEPTICAL, `Well, I guess she could be considered attractive...`], + romeo => [Emote.LAUGH, `I'll bet she is! Wooooooooo!`], + romeo => [Emote.POMPOUS, `Sorry, all that jubilation has made me forget what we were talking about.`], + player => [Emote.GENERIC, `You were asking me about Juliet? You seemed to know her?`], + romeo => [Emote.HAPPY, `Oh yes, Juliet!`], + romeo => [Emote.HAPPY, `The fox...could you tell her that she is the love of my long and that I life to be with her?`], + player => [Emote.WONDERING, `What? Surely you mean that she is the love of your life and that you long to be with her?`], + romeo => [Emote.HAPPY, `Oh yeah...what you said...tell her that, it sounds much better! Oh you're so good at this!`] + ], + `No sorry, I haven't seen her.`, [ + player => [Emote.GENERIC, `No sorry, I haven't seen her.`], + romeo => [Emote.SAD, `Oh...well, that's a shame...I was rather hoping you had.`], + player => [Emote.WONDERING, `Why? Is she a fugitive? Does she owe you some money or something?`], + romeo => [Emote.WONDERING, `Hmmm, she might do? Perhaps she does? How do you know?`], + player => [Emote.ANGRY, `I don't know? I was asking 'YOU' how 'YOU' know Juliet!`], + romeo => [Emote.HAPPY, `Ahh, yes Juliet, she's my one true love. Well, one of my one true loves! If you see her, could you tell her that she is the love of my long and that I life to be with her?`], + player => [Emote.WONDERING, `What? Surely you mean that she is the love of your life and that you long to be with her?`], + romeo => [Emote.HAPPY, `Oh yeah...what you said...tell her that, it sounds much better! Oh you're so good at this!`] + ], + `Perhaps I could help to find her for you? `, [ + player => [Emote.HAPPY, `Perhaps I can help find her for you? What does she look like?`], + romeo => [Emote.HAPPY, `Oh would you? That would be great! She has this sort of hair...`], + player => [Emote.WONDERING, `Hair...check..`], + romeo => [Emote.HAPPY, `...and she these...great lips...`], + player => [Emote.WONDERING, `Lips...right.`], + romeo => [Emote.HAPPY, `Oh and she has these lovely shoulders as well..`], + player => [Emote.GENERIC, `Shoulders...right, so she has hair, lips and shoulders...that should cut it down a bit.`], + romeo => [Emote.HAPPY, `Oh yes, Juliet is very different...please tell her that she is the love of my long and that I life to be with her?`], + player => [Emote.WONDERING, `What? Surely you mean that she is the love of your life and that you long to be with her?`], + romeo => [Emote.HAPPY, `Oh yeah...what you said...tell her that, it sounds much better! Oh you're so good at this!`] + ] + ] + ]); + + await dialogue(participants, [ + options => [ + `Yes, ok, I'll let her know.`, [ + execute(() => { + player.setQuestProgress('rs:romeo_and_juliet', 1); + }), + player => [Emote.GENERIC, `Yes, ok, I'll let her know.`], + romeo => [Emote.HAPPY, `Oh great! And tell her that I want to kiss her a give.`], + player => [Emote.ANGRY, `You mean you want to give her a kiss!`], + romeo => [Emote.HAPPY, `Oh you're good...you are good!`], + romeo => [Emote.HAPPY, `I see I've picked a true professional...!`], + moreInfo() + ], + `Sorry Romeo, I've got better things to do right now but maybe later?`, [ + player => [Emote.GENERIC, `Sorry Romeo, I've got better things to do right now but maybe later?`], + romeo => [Emote.SAD, `Oh, ok, well, I guess my Juliet and I can spend some time apart. And as the old saying goes, 'Absinthe makes the heart glow longer'.`], + player => [Emote.WONDERING, `Don't you mean that, 'Absence makes the...`], + player => [Emote.GENERIC, `Actually forget it...`], + romeo => [Emote.WONDERING, `Ok!`] + ] + ] + ]); + }, + + 1: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'romeo' }]; + await dialogue(participants, [ + player => [Emote.GENERIC, `Hello again, remember me?`], + romeo => [Emote.WONDERING, `Of course, yes....how are.. you....ermmm...`], + player => [Emote.ANGRY, `You haven't got a clue who I am do you?`], + romeo => [Emote.GENERIC, `Not a clue my friend, but you seem to have a friendly face...a little blood stained, and perhaps in need of a wash, but friendly none the less.`], + player => [Emote.ANGRY, `You asked me to look for Juliet for you!`], + romeo => [Emote.HAPPY, `Ah yes, Juliet...my sweet darling...what news?`], + player => [Emote.WONDERING, `Nothing so far, but I need to ask a few questions? `], + moreInfo() + ]); + }, + + 2: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'romeo' }]; + + const hasLetterInInventory = player.hasItemInInventory(questItems.julietLetter.gameId); + if (!hasLetterInInventory) { + await dialogue(participants, [ + player => [Emote.HAPPY, `Romeo...great news...I've been in touch with Juliet!`], + romeo => [Emote.HAPPY, `Oh great! That is great news! Well done...well done... what a total success!`], + player => [Emote.HAPPY, `Yes, and she gave me a message to give you...`], + romeo => [Emote.HAPPY, `Ohhh great! A message....wow!`], + player => [Emote.HAPPY, `Yes!`], + romeo => [Emote.HAPPY, `A message...oh, I can't wait to read what my dear Juliet has to say....`], + player => [Emote.HAPPY, `I know...it's exciting isn't it...?`], + romeo => [Emote.HAPPY, `Yes...yes...`], + romeo => [Emote.BLANK_STARE, `...`], + romeo => [Emote.SKEPTICAL, `You've lost the message haven't you?`], + player => [Emote.GENERIC, `Yep, haven't got a clue where it is.`] + ]); + return; + } + + const completedDialogue = await dialogue(participants, [ + player => [Emote.HAPPY, `Romeo...great news...I've been in touch with Juliet! She's written a message for you...`], + item => [questItems.julietLetter.gameId, `You hand over Juliet's message to Romeo.`], + romeo => [Emote.HAPPY, `Oh, a message! A message! I've never had a message before...`], + player => [Emote.POMPOUS, `Really?`], + romeo => [Emote.HAPPY, `No, no, not one!`], + romeo => [Emote.POMPOUS, `Oh, well, except for the occasional court summons.`], + romeo => [Emote.HAPPY, `But they're not really 'nice' messages. Not like this one! I'm sure that this message will be lovely.`], + player => [Emote.POMPOUS, `Well are you going to open it or not?`], + romeo => [Emote.HAPPY, `Oh yes, yes, of course! 'Dearest Romeo, I am very pleased that you sent ${player.username} to look for me and to tell me that you still hold affliction...', Affliction! She thinks I'm diseased?`], + player => [Emote.VERY_SAD, `'Affection?'`], + romeo => [Emote.SAD, `Ahh yes...'still hold affection for me. I still feel great affection for you, but unfortunately my Father opposes our marriage.'`], + player => [Emote.SAD, `Oh dear...that doesn't sound too good.`], + romeo => [Emote.HAPPY, `What? '...great affection for you. Father opposes our..'`], + romeo => [Emote.SAD, `'...marriage and will...`], + romeo => [Emote.SHOCKED, `...will kill you if he sees you again!'`], + player => [Emote.SKEPTICAL, `I have to be honest, it's not getting any better...`], + romeo => [Emote.SAD, `'Our only hope is that Father Lawrence, our long time confidant, can help us in some way.'`], + item => [questItems.julietLetter.gameId, `Romeo folds the message away.`], + romeo => [Emote.SAD, `Well, that's it then...we haven't got a chance...`], + player => [Emote.SAD, `What about Father Lawrence?`], + romeo => [Emote.SAD, `...our love is over...the great romance, the life of my love...`], + player => [Emote.POMPOUS, `...or you could speak to Father Lawrence!`], + romeo => [Emote.SAD, `Oh, my aching, breaking, heart...how useless the situation is now...we have no one to turn to...`], + player => [Emote.ANGRY, `FATHER LAWRENCE!`] + ]); + + if (!completedDialogue) { + return; + } + + const slotRemoved = player.removeFirstItem(questItems.julietLetter.gameId); + if (slotRemoved === -1) { + return; + } + + player.setQuestProgress(questKey, 3); + + await dialogue(participants, [ + romeo => [Emote.SHOCKED, `Father Lawrence?`], + // TODO the next 2 lines should be 1 dialogue + romeo => [Emote.HAPPY, `Oh yes, Father Lawrence...he's our long time confidant, he might have a solution!`], + romeo => [Emote.HAPPY, `Yes, yes, you have to go and talk to Lather Fawrence for us and ask him if he's got any suggestions for our predicament?`], + player => [Emote.POMPOUS, `Where can I find Father Lawrence?`], + romeo => [Emote.HAPPY, `Lather Fawrence! Oh he's...`], + romeo => [Emote.SKEPTICAL, `You know he's not my 'real' Father don't you?`], + player => [Emote.VERY_SAD, `I think I suspected that he wasn't.`], + romeo => [Emote.HAPPY, `Well anyway...he tells these song, loring bermons...and keeps these here Carrockian vitizens snoring in his church to the East North.`], + findingFatherLawrence() + ]); + }, + + 3: async (player: Player, npc: Npc) => { + const participants = [player, { npc, key: 'romeo' }]; + await dialogue(participants, [ + player => [Emote.POMPOUS, `Hey again Romeo!`], + findingFatherLawrence() + ]); + }, + + 4: async (player: Player, npc: Npc) => { + // Placeholder for the cutscene at the end, I was bored so I decided to skip to this one + const participants = [player, { npc, key: 'romeo' }]; + const cont = await dialogue(participants, [ + player => [Emote.HAPPY, `Romeo, it's all set. Juliet has drunk the potion and has been taken down into the Crypt...now you just need to pop along and collect her.`], + romeo => [Emote.HAPPY, `Ah, right, the potion! Great...`], + romeo => [Emote.WONDERING, `What potion would that be then?`], + player => [Emote.SHOCKED, `The Cadava potion...you know, the one which will make her appear dead! She's in the crypt, pop along and claim your true love.`], + romeo => [Emote.WORRIED, `But I'm scared...will you come with me?`], + player => [Emote.GENERIC, `Oh , ok...come on! I think I saw the entrance when I visited there last...`] + ]); + + if (!cont) { + return; + } + + await player.interfaceState.fadeOutScreen(); + player.instance = new WorldInstance(uuidv4()); + player.teleport(new Position(2333, 4646)); + player.cutscene = new Cutscene(player, { hideTabs: true, hideMinimap: true }); + const cutsceneRomeo = await world.spawnNpc('rs:romeo', new Position(2333, 4645), 'NORTH', 0, player.instance.instanceId); + player.face(cutsceneRomeo, true, false, false); + + await dialogue(participants, [ + romeo => [Emote.WORRIED, `This is pretty scary...`], + player => [Emote.WORRIED, `Oh , be quiet...`], + ], { + multi: true + }); + + await player.interfaceState.fadeInScreen(); + + player.cutscene.snapCameraTo(2330, 4641); + player.cutscene.lookAt(2333, 4645, 300); + + await schedule(2); + + await dialogue(participants, [ + player => [Emote.HAPPY, `We're here. Look, Juliet is over there!`] + ]); + + player.cutscene.snapCameraTo(2334, 4647, 375, 10, 0); + player.cutscene.lookAt(2322, 4639, 300, 10, 0); + + await schedule(6); + + player.cutscene.snapCameraTo(2324, 4644, 250, 6, 0); + player.cutscene.lookAt(2321, 4640, 250, 6, 0); + + await schedule(10); + + player.cutscene.snapCameraTo(2330, 4641); + player.cutscene.lookAt(2333, 4645, 300); + + await schedule(1); + + await dialogue(participants, [ + player => [Emote.HAPPY, `You go over to her...and I'll go and wait over here...`], + romeo => [Emote.WORRIED, `Ohhh, ok then...`], + ]); + + player.cutscene.snapCameraTo(2322, 4639, 300); + player.cutscene.lookAt(2324, 4644, 300); + + cutsceneRomeo.walkingQueue.valid = true; + cutsceneRomeo.walkingQueue.add(2329, 4645); + cutsceneRomeo.walkingQueue.add(2327, 4645); + cutsceneRomeo.walkingQueue.add(2325, 4644); + cutsceneRomeo.walkingQueue.add(2323, 4643); + cutsceneRomeo.face(new Position(2322, 4642), false, false, true); + await cutsceneRomeo.isIdle(); + + const cutscenePhillipa = await world.spawnNpc('rs:phillipa', new Position(2333, 4645), 'SOUTHWEST', 0, player.instance.instanceId); + cutscenePhillipa.walkingQueue.valid = true; + cutscenePhillipa.walkingQueue.add(2327, 4645); + cutscenePhillipa.walkingQueue.add(2325, 4644); + cutscenePhillipa.face(cutsceneRomeo.position, false, false, true); + + const finalParticipants = [player, { npc, key: 'romeo' }, { npc: 'rs:phillipa', key: 'phillipa' }]; + await dialogue(finalParticipants, [ + romeo => [Emote.WORRIED, `Hey...Juliet...`], + romeo => [Emote.WORRIED, `Juliet...?`], + romeo => [Emote.SAD, `Oh dear...you seem to be dead.`], + phillipa => [Emote.HAPPY, `Hi Romeo...I'm Phillipa!`], + ]); + + cutsceneRomeo.face(cutscenePhillipa, false, false, false); + + await dialogue(finalParticipants, [ + romeo => [Emote.HAPPY, `Wow! You're a fox!`], + phillipa => [Emote.HAPPY, `It's a shame about Juliet...but perhaps we can meet up later?`], + romeo => [Emote.HAPPY, `Who's Juliet?`], + ]); + + await player.interfaceState.fadeOutScreen(); + player.cutscene.endCutscene(); + player.teleport(new Position(3212, 3424)); + player.instance = null; + await player.interfaceState.fadeInScreen(); + + player.setQuestProgress('rs:romeo_and_juliet', 'complete'); + } +}