From 632bf07831e3839875f4c4bb5262292374ab8798 Mon Sep 17 00:00:00 2001 From: Payton Kerby Date: Wed, 22 Jan 2025 10:20:29 -0600 Subject: [PATCH] feat: added `EntityNpcTrait` --- packages/core/src/entity/traits/index.ts | 1 + packages/core/src/entity/traits/npc.ts | 162 ++++++++++++++++++++++ packages/core/src/handlers/npc-request.ts | 87 ++++++++++-- packages/core/src/ui/dialogue.ts | 47 ++++++- 4 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/entity/traits/npc.ts diff --git a/packages/core/src/entity/traits/index.ts b/packages/core/src/entity/traits/index.ts index 9e752ff0..6e9f5d91 100644 --- a/packages/core/src/entity/traits/index.ts +++ b/packages/core/src/entity/traits/index.ts @@ -10,3 +10,4 @@ export * from "./physics"; export * from "./effects"; export * from "./visibility"; export * from "./equipment"; +export * from "./npc"; diff --git a/packages/core/src/entity/traits/npc.ts b/packages/core/src/entity/traits/npc.ts new file mode 100644 index 00000000..f8db4fdc --- /dev/null +++ b/packages/core/src/entity/traits/npc.ts @@ -0,0 +1,162 @@ +import { + ActorDataId, + ActorDataType, + DataItem, + Gamemode +} from "@serenityjs/protocol"; + +import { EntityIdentifier, EntityInteractMethod } from "../../enums"; +import { JSONLikeObject } from "../../types"; +import { Player } from "../player"; +import { DialogueForm } from "../../ui"; + +import { EntityTrait } from "./trait"; + +interface EntityNpcDialogueComponent extends JSONLikeObject { + /** + * The title of the npc dialogue. + */ + title: string; + + /** + * The dialogue content of the npc. + */ + dialogue: string; + + /** + * The buttons of the npc dialogue. [ButtonName, Command] + */ + buttons: Array<[string, string]>; +} + +class EntityNpcTrait extends EntityTrait { + public static readonly identifier = "npc"; + public static readonly types = [EntityIdentifier.Npc]; + + /** + * The component used to store the npc dialogue form data. + */ + public get component(): EntityNpcDialogueComponent { + return this.entity.getComponent("npc") as EntityNpcDialogueComponent; + } + + /** + * The title of the npc dialogue form. + */ + public get title(): string { + return this.component.title; + } + + /** + * The title of the npc dialogue form. + */ + public set title(value: string) { + this.component.title = value; + } + + /** + * The dialogue content of the npc dialogue form. + */ + public get dialogue(): string { + return this.component.dialogue; + } + + /** + * The dialogue content of the npc dialogue form. + */ + public set dialogue(value: string) { + this.component.dialogue = value; + } + + /** + * The buttons of the npc dialogue form. + */ + public get buttons(): Array<[string, string]> { + return this.component.buttons; + } + + /** + * The buttons of the npc dialogue form. + */ + public set buttons(value: Array<[string, string]>) { + this.component.buttons = value; + } + + /** + * Adds a button to the npc dialogue form. + * @param text The text of the button. + * @returns The index of the button. + */ + public addButton(text: string, command = ""): number { + // Add the button to the npc dialogue form + this.component.buttons.push([text, command]); + + // Return the index of the button + return this.component.buttons.length - 1; + } + + public onAdd(): void { + // Check if the entity has a npc component + if (this.entity.hasComponent(this.identifier)) return; + + // Add the npc component to the entity + this.entity.addComponent(this.identifier, { + title: "NPC", + dialogue: "", + buttons: [] + }); + + // Create a new metadata item for the npc component + const metadata = new DataItem(ActorDataId.HasNpc, ActorDataType.Byte, 1); + + // Add the metadata item to the entity + this.entity.metadata.set(ActorDataId.HasNpc, metadata); + } + + public onRemove(): void { + // Remove the npc component from the entity + this.entity.removeComponent(this.identifier); + + // Remove the metadata item from the entity + this.entity.metadata.delete(ActorDataId.HasNpc); + } + + public onInteract(player: Player, method: EntityInteractMethod): void { + // Check if the player is in creative mode and attacking the entity + if ( + method === EntityInteractMethod.Attack && + player.gamemode === Gamemode.Creative + ) { + // Remove the entity from the dimension + this.entity.despawn(); + } + + // Check if the entity is not being interacted with (right-click) + if (method !== EntityInteractMethod.Interact) return; + + // Create a new dialogue form for the entity, and indicate that it is from a trait + const form = new DialogueForm(this.entity, true); + form.title = this.title; + form.content = this.dialogue; + + // Add the buttons to the dialogue form + for (const [text] of this.buttons) form.button(text); + + // Show the form to the player + form.show(player, (index, error) => { + // Check if the index is null or an error occurred + if (index === null || error) return; + + // Get the command from the button index + const command = this.buttons[index] ? this.buttons[index][1] : ""; + + // Execute the command if it is not empty + if (command.length > 0) player.executeCommand(command); + + // Close the form for the player + return form.close(player); + }); + } +} + +export { EntityNpcTrait }; diff --git a/packages/core/src/handlers/npc-request.ts b/packages/core/src/handlers/npc-request.ts index 2f351a30..663968e2 100644 --- a/packages/core/src/handlers/npc-request.ts +++ b/packages/core/src/handlers/npc-request.ts @@ -2,6 +2,8 @@ import { NpcRequestPacket, NpcRequestType, Packet } from "@serenityjs/protocol"; import { Connection } from "@serenityjs/raknet"; import { NetworkHandler } from "../network"; +import { DialogueForm, DialogueFormProperties } from "../ui"; +import { EntityNpcTrait } from "../entity"; class NpcRequestHandler extends NetworkHandler { public static readonly packet = Packet.NpcRequest; @@ -11,27 +13,84 @@ class NpcRequestHandler extends NetworkHandler { const player = this.serenity.getPlayerByConnection(connection); if (!player) return connection.disconnect(); - // Get the form id from the packet. - const formId = JSON.parse(packet.scene).formId; + // Check if the packet has properties + const hasProperties = packet.scene.length > 0; + + // Get the form properties from the packet scene. + const { formId, fromTrait } = hasProperties + ? (JSON.parse(packet.scene) as DialogueFormProperties) + : { formId: -1, fromTrait: null }; // Provide default values. + + // Check if the form id is -1 and the from trait is null. + // If so, we can indicate that the form that request is to modify the contents of the dialogue. + // But, we need to check if the targeted entity has a npc trait. If not, then we should close the form. + if (formId === -1 && fromTrait === null) { + // Get the entity from the packet unique entity id. + const entity = player.dimension.getEntity(packet.runtimeActorId, true); + + // Check if the entity exists. + if (!entity) + throw new Error( + `Entity with runtime id ${packet.runtimeActorId} not found.` + ); + + // Check if the entity has a npc trait. + if (!entity.hasTrait(EntityNpcTrait)) + return DialogueForm.closeForm(player, entity.uniqueId); + + // Get the npc trait from the entity. + const npc = entity.getTrait(EntityNpcTrait); + + // Switch the packet type to handle the npc request. + switch (packet.type) { + case NpcRequestType.SetName: { + // Update the entity name tag with the packet actions. + entity.nameTag = packet.actions; + break; + } + + case NpcRequestType.SetInteractText: { + // Update the npc dialogue with the packet actions. + npc.dialogue = packet.actions; + break; + } + } + + return; + } // Get the form from the player's pending forms. + // And check if the form exists, if not delete the form. const form = player.pendingForms.get(formId); - if (!form) return; + if (!form) return void player.pendingForms.delete(formId); - switch (packet.type) { - // Return if the packet type is not an execute action. - default: - return; + // Get the form instance from the form. + const instance = form.instance as DialogueForm; + // Check if the form response was derived from a trait. + if (fromTrait) { + switch (packet.type) { + case NpcRequestType.ExecuteAction: { + // Get the button index from the packet. + const index = packet.index as number; - case NpcRequestType.ExecuteAction: { - // Call the form result with the packet index. - form.result(packet.index as unknown as null, null); - break; + // Call the form result with the index. + return form.result(index as unknown as null, null); + } } - } + } else { + // Check if the action was to click a button. + // If not, return the form result with a null value. + if (packet.type !== NpcRequestType.ExecuteAction) return; - // Close the form for the player. - form.instance.close(player); + // Call the form result with the packet index. + form.result(packet.index as unknown as null, null); + + // Close the form for the player. + instance.close(player); + + // Delete the form from the pending forms map. + player.pendingForms.delete(formId); + } } } diff --git a/packages/core/src/ui/dialogue.ts b/packages/core/src/ui/dialogue.ts index 36b6b83b..991601c4 100644 --- a/packages/core/src/ui/dialogue.ts +++ b/packages/core/src/ui/dialogue.ts @@ -18,6 +18,18 @@ interface DialogueFormButton { text: string; } +interface DialogueFormProperties { + /** + * The form id of the dialogue form. + */ + formId: number; + + /** + * Whether the dialogue form is from a trait. + */ + fromTrait: boolean; +} + class DialogueForm extends Form { public readonly type = ModalFormType.Dialogue; @@ -36,6 +48,11 @@ class DialogueForm extends Form { */ public content: string = ""; + /** + * Whether the dialogue form is from a trait. + */ + public fromTrait: boolean; + /** * The buttons of the dialogue form. */ @@ -44,12 +61,14 @@ class DialogueForm extends Form { /** * Creates a new dialogue form. * @param target The target entity that the dialogue is focused on. + * @param fromTrait Whether the dialogue form is from a trait. */ - public constructor(target: Entity) { + public constructor(target: Entity, fromTrait = false) { super(); // Set the properties of the dialogue form. this.target = target; + this.fromTrait = fromTrait; } /** @@ -91,7 +110,15 @@ class DialogueForm extends Form { packet.uniqueEntityId = this.target.uniqueId; packet.action = NpcDialogueAction.Open; packet.dialogue = this.content; - packet.scene = JSON.stringify({ formId: this.formId }); + + // Create the dialogue form properties + const properties: DialogueFormProperties = { + formId: this.formId, + fromTrait: this.fromTrait + }; + + // Set the scene to the form id and from trait. + packet.scene = JSON.stringify(properties); packet.name = this.title; packet.json = JSON.stringify(buttons); @@ -118,6 +145,20 @@ class DialogueForm extends Form { // Send the packet to the player. player.send(packet); } + + public static closeForm(player: Player, id: bigint): void { + // Create a new NpcDialoguePacket. + const packet = new NpcDialoguePacket(); + packet.uniqueEntityId = id; + packet.action = NpcDialogueAction.Close; + packet.dialogue = String(); + packet.scene = JSON.stringify({ formId: -1 }); + packet.name = String(); + packet.json = String(); + + // Send the packet to the player. + player.send(packet); + } } -export { DialogueForm }; +export { DialogueForm, DialogueFormButton, DialogueFormProperties };