From aeee90070c11c7dd02ef78f47dc16af896a00536 Mon Sep 17 00:00:00 2001 From: Arturas Date: Thu, 8 Dec 2022 01:47:47 +0000 Subject: [PATCH] feat: Enemy special attack trigger --- src/lib/util/eqneq.mts | 16 ++++ src/option-types/alt-cost-option.tsx | 7 +- src/option-types/media-item-option.mts | 3 +- .../media-item/media-edit-base.tsx | 86 ++++++++++++------- .../media-item/render-media-edit.tsx | 7 +- .../media-item/render-media-view.tsx | 19 ++-- src/public_api/option.d.ts | 10 +++ src/setup.tsx | 8 +- .../combat/enemy-special-attack-trigger.tsx | 52 +++++++++++ src/triggers/combat/index.mts | 2 + src/triggers/core/index.mts | 5 ++ src/triggers/index.mts | 8 +- src/ui/assets/styles.scss | 5 ++ types/melvor/game/combat.d.ts | 10 ++- types/melvor/game/core.d.ts | 4 +- types/melvor/global.d.ts | 1 + 16 files changed, 190 insertions(+), 53 deletions(-) create mode 100644 src/lib/util/eqneq.mts create mode 100644 src/triggers/combat/enemy-special-attack-trigger.tsx create mode 100644 src/triggers/combat/index.mts create mode 100644 src/triggers/core/index.mts diff --git a/src/lib/util/eqneq.mts b/src/lib/util/eqneq.mts new file mode 100644 index 0000000..38daaa0 --- /dev/null +++ b/src/lib/util/eqneq.mts @@ -0,0 +1,16 @@ +export const enum EqNeq { + EQ = '=', + NEQ = '!=', +} + +type EqNeqFn = (a: T, b: T) => boolean; + +const compareFns = new Map>([ + [EqNeq.EQ, (a, b) => a === b], + [EqNeq.NEQ, (a, b) => a !== b], +]); + +/** Get the comparison function for {@link EqNeq} */ +export function getEqNeqFn(eqNeq: EqNeq): EqNeqFn { + return compareFns.get(eqNeq)!; +} diff --git a/src/option-types/alt-cost-option.tsx b/src/option-types/alt-cost-option.tsx index a29de35..e5561ab 100644 --- a/src/option-types/alt-cost-option.tsx +++ b/src/option-types/alt-cost-option.tsx @@ -3,7 +3,7 @@ import {h} from 'preact'; import {useCallback} from 'preact/hooks'; import type {OptionRenderViewCtx} from '../lib/define-option.mjs'; import {defineOption} from '../lib/define-option.mjs'; -import {EMPTY_ARR} from '../lib/util.mjs'; +import {EMPTY_ARR, EMPTY_OBJ} from '../lib/util.mjs'; import type {AltRecipeCostNodeOption, MediaSelectable, Obj} from '../public_api'; import type {MediaOptionValue} from './media-item-option.mjs'; import RenderMediaItemOptionOneBase from './media-item/media-edit-base'; @@ -64,7 +64,10 @@ defineOption({ return null; } - const ctx: Partial> = {value: altCostItem}; + const ctx: Partial> = { + option: EMPTY_OBJ, + value: altCostItem, + }; return h(RenderMediaSelectView, ctx as OptionRenderViewCtx); }, diff --git a/src/option-types/media-item-option.mts b/src/option-types/media-item-option.mts index 4aee841..64c7559 100644 --- a/src/option-types/media-item-option.mts +++ b/src/option-types/media-item-option.mts @@ -70,10 +70,11 @@ defineOption({ }); export default function isMediaItemOption(v: NodeOptionBase & Obj): v is MediaItemNodeOption { - const {type, mediaFilter, registry, multi} = v as Partial; + const {type, itemRender, mediaFilter, registry, multi} = v as Partial; return type === 'MediaItem' && isUndefinedOr(mediaFilter, 'function') + && isUndefinedOr(itemRender, 'function') && ( typeof registry === 'function' || ( diff --git a/src/option-types/media-item/media-edit-base.tsx b/src/option-types/media-item/media-edit-base.tsx index 5dc18d4..29a2c1e 100644 --- a/src/option-types/media-item/media-edit-base.tsx +++ b/src/option-types/media-item/media-edit-base.tsx @@ -1,7 +1,7 @@ import type {ReadonlySignal} from '@preact/signals'; import {useComputed, useSignal} from '@preact/signals'; -import type {VNode} from 'preact'; -import type {FunctionComponent} from 'preact/compat'; +import type {ComponentType, VNode} from 'preact'; +import {Fragment} from 'preact'; import {memo} from 'preact/compat'; import type {Ref} from 'preact/hooks'; import {useCallback, useEffect, useRef, useState} from 'preact/hooks'; @@ -12,12 +12,15 @@ import Btn from '../../ui/components/btn'; import useTippy from '../../ui/hooks/tippy.mjs'; import {useRenderEditTouch} from '../_common.mjs'; -interface Props extends Pick, 'onChange' | 'value'> { +interface Props extends + Pick, 'onChange' | 'value'>, + Pick { + filterFn(filterText: string): T[]; } const RenderMediaItemOptionOneBase = memo( - function ({filterFn, value, onChange}: Props): VNode { + function ({filterFn, icon, value, onChange, itemRender}: Props): VNode { const [focus, setFocus] = useState(false); const changeAndFocus = useCallback((val?: T): void => { setFocus(true); @@ -25,33 +28,40 @@ const RenderMediaItemOptionOneBase = memo( }, [onChange]); return value - ? () - : (); + ? () + : ( + + ); } ); export default RenderMediaItemOptionOneBase; -type BtnProps = Required, 'value' | 'onChange'>>; +type BtnProps = Required, 'value' | 'onChange'>> & Pick, 'icon'>; -function RenderBtn({value, onChange}: BtnProps): VNode { +function RenderBtn({icon = true, value, onChange}: BtnProps): VNode { const unselect = useCallback(() => { onChange(undefined); }, [onChange]); return ( - + {icon && } {value.name} ); } -interface InnerProps extends Pick, 'filterFn' | 'onChange'> { +interface InnerProps extends Pick, 'filterFn' | 'onChange' | 'itemRender' | 'icon'> { focus: boolean; } -function RenderFilter({focus, filterFn, onChange}: InnerProps) { +function RenderFilter({focus, filterFn, icon, itemRender, onChange}: InnerProps) { const filterText = useSignal(''); const results = useComputed((): T[] => { const txt = filterText.value; @@ -88,29 +98,55 @@ function RenderFilter({focus, filterFn, onChange}: In return (
- - +
); } interface ItemsRenderProps { + + /** @default true */ + icon?: boolean; + + itemRender?: ComponentType<{item: T;}> + results: ReadonlySignal; onItemClick(e: Event): void; } -const ItemsRender = memo(function ({onItemClick, results}: ItemsRenderProps): VNode { - return ( -
- {results.value.map(itemMapper)} -
- ); -}); -(ItemsRender as FunctionComponent).displayName = 'ItemsRender'; +function DefaultItemRender({item}: {item: T}): VNode { + return {item.name}; +} +const ItemsRender = memo( + function ({ + icon = true, + itemRender: ItemRender = DefaultItemRender, + onItemClick, + results, + }: ItemsRenderProps): VNode { + const classKey = icon ? '' : 'p-1 pl-2 pr-2 list-group-item list-group-item-dark list-group-item-action'; + + return ( +
+ {results.value.map((item, idx) => ( + + { + icon + ? + : + } + + ))} +
+ ); + } +); /** Focus an element this ref gets attached to on init if `focus` is true */ function useFocus(focus: boolean): Ref { @@ -124,14 +160,6 @@ function useFocus(focus: boolean): Ref { return ref; } -function itemMapper(item: T, idx: number): VNode { - return ( - - - - ); -} - function ItemImg({item: {media, name}}: {item: T}): VNode { const ref = useTippy(name); diff --git a/src/option-types/media-item/render-media-edit.tsx b/src/option-types/media-item/render-media-edit.tsx index 93258a3..ab3864b 100644 --- a/src/option-types/media-item/render-media-edit.tsx +++ b/src/option-types/media-item/render-media-edit.tsx @@ -104,7 +104,7 @@ interface EditOneProps extends OptionRenderEditCtx { const RenderEditOne = memo(function ({ onChange, - option: {mediaFilter, registry}, + option: {itemRender, icon, mediaFilter, registry}, otherValues, value, }: EditOneProps) { @@ -118,7 +118,10 @@ const RenderEditOne = memo(function ({ })]), [otherValues, reg, mediaFilter]); return ( - ); diff --git a/src/option-types/media-item/render-media-view.tsx b/src/option-types/media-item/render-media-view.tsx index 59037b0..d1c8aae 100644 --- a/src/option-types/media-item/render-media-view.tsx +++ b/src/option-types/media-item/render-media-view.tsx @@ -9,7 +9,7 @@ import type {MediaOptionValue} from '../media-item-option.mjs'; type PartialMedia = Pick; const RenderMediaSelectView: OptionDefinition['renderView'] - = ({value}) => { + = ({value, option: {icon = true}}) => { if (Array.isArray(value)) { const items: RenderNodeMediaProps[] = []; for (const v of value) { @@ -20,9 +20,11 @@ const RenderMediaSelectView: OptionDefinition); - } else if (isPartialMediaSelectable(value)) { - return (); + return (); + } else if (icon && isPartialMediaSelectable(value)) { + return ; + } else if (value?.name) { + return {value.name}; } return null; @@ -34,11 +36,16 @@ function isPartialMediaSelectable(v: any): v is PartialMedia { return typeof v?.name === 'string' && typeof v.media === 'string'; } -const RenderViewMulti = ({items}: {items: RenderNodeMediaProps[]}): VNode => ( +interface MultiProps { + icon: boolean; + + items: RenderNodeMediaProps[]; +} +const RenderViewMulti = ({items, icon}: MultiProps): VNode => ( {items.map(item => (
- + {icon ? : item.label}
))}
diff --git a/src/public_api/option.d.ts b/src/public_api/option.d.ts index 88b4e33..aded4a7 100644 --- a/src/public_api/option.d.ts +++ b/src/public_api/option.d.ts @@ -1,4 +1,5 @@ import type {Item} from 'melvor'; +import {ComponentType} from 'preact'; import type {DynamicOption} from '../lib/util/dynamic-option.mjs'; import type {NodeOptionBase, Obj} from './core'; @@ -74,6 +75,15 @@ export interface MediaItemNodeOptionMultiConfig { /** Select from pretty much any in-game registry */ export interface MediaItemNodeOption extends NodeOptionBase { + /** + * Whether the icon should be shown or not + * @default true + */ + icon?: boolean; + + /** Custom component for rendering items in select mode when `icon` is `false` */ + itemRender?: ComponentType<{item: any;}>; + /** * Select one (`false`) or multiple (`true`/`object`) values? * @default false diff --git a/src/setup.tsx b/src/setup.tsx index fbfc988..5304dca 100644 --- a/src/setup.tsx +++ b/src/setup.tsx @@ -17,6 +17,11 @@ setDefaultLogger(errorLog); let sidenavIconContainer: Signal; +ctx.onCharacterLoaded(() => { + sidenavIconContainer = signal(null); + render(, document.createElement('div')); +}); + ctx.onInterfaceReady(() => { // Don't start checking triggers for offline time for (const {def, id} of TRIGGER_REGISTRY.registeredObjects.values()) { @@ -27,9 +32,6 @@ ctx.onInterfaceReady(() => { } } - sidenavIconContainer = signal(null); - render(, document.createElement('div')); - sidebar .category('') .item('Action Workflows', { diff --git a/src/triggers/combat/enemy-special-attack-trigger.tsx b/src/triggers/combat/enemy-special-attack-trigger.tsx new file mode 100644 index 0000000..5a5af12 --- /dev/null +++ b/src/triggers/combat/enemy-special-attack-trigger.tsx @@ -0,0 +1,52 @@ +import type {SpecialAttack as TSpecialAttack} from 'melvor'; +import {Fragment} from 'preact'; +import {InternalCategory} from '../../lib/registries/action-registry.mjs'; +import {defineLocalTrigger} from '../../lib/util/define-local.mjs'; +import {EqNeq, getEqNeqFn} from '../../lib/util/eqneq.mjs'; + +interface Data { + atk: TSpecialAttack; + + match: EqNeq; +} + +const triggerCtx = defineLocalTrigger({ + category: InternalCategory.COMBAT, + check: d => game.combat.isActive && getEqNeqFn(d.match)(game.combat.enemy.nextAttack.id, d.atk.id), + init() { + ctx.patch(Enemy, 'queueNextAction').after(() => { + const atk = game.combat.enemy.nextAttack.id; + triggerCtx.notifyListeners(d => getEqNeqFn(d.match)(atk, d.atk.id)); + }); + }, + initOptions: () => ({match: EqNeq.EQ}), + label: 'Monster attack', + localID: 'enemyAtk', + media: cdnMedia('assets/media/bank/Mask_of_Torment.png'), + options: [ + { + icon: false, + itemRender: ({item}: {item: TSpecialAttack}) => ( + + {`${item.name} `} + {item.description} + + ), + label: 'Attack', + localID: 'atk', + registry: 'specialAttacks', + required: true, + type: 'MediaItem', + }, + { + enum: { + [EqNeq.EQ]: 'Casting attack', + [EqNeq.NEQ]: 'Not casting attack', + }, + label: 'Match', + localID: 'match', + required: true, + type: String, + }, + ], +}); diff --git a/src/triggers/combat/index.mts b/src/triggers/combat/index.mts new file mode 100644 index 0000000..5ca7c72 --- /dev/null +++ b/src/triggers/combat/index.mts @@ -0,0 +1,2 @@ +import './enemy-id-trigger.mjs'; +import './enemy-special-attack-trigger'; diff --git a/src/triggers/core/index.mts b/src/triggers/core/index.mts new file mode 100644 index 0000000..c71c134 --- /dev/null +++ b/src/triggers/core/index.mts @@ -0,0 +1,5 @@ +import './and-or-trigger.mjs'; +import './item-quantity-trigger.mjs'; +import './level-gained-trigger.mjs'; +import './mastery-level-trigger.mjs'; +import './mastery-pool-xp-trigger.mjs'; diff --git a/src/triggers/index.mts b/src/triggers/index.mts index 41dd1d1..981348e 100644 --- a/src/triggers/index.mts +++ b/src/triggers/index.mts @@ -1,7 +1,3 @@ import '../option-types/option-types.mjs'; -import './combat/enemy-id-trigger.mjs'; -import './core/and-or-trigger.mjs'; -import './core/item-quantity-trigger.mjs'; -import './core/level-gained-trigger.mjs'; -import './core/mastery-level-trigger.mts'; -import './core/mastery-pool-xp-trigger.mts'; +import './combat/index.mjs'; +import './core/index.mjs'; diff --git a/src/ui/assets/styles.scss b/src/ui/assets/styles.scss index 22208c6..da0e08b 100644 --- a/src/ui/assets/styles.scss +++ b/src/ui/assets/styles.scss @@ -1,6 +1,11 @@ $colour-err: #e56767; .ActionWorkflowsCore- { + &list-mh { + max-height: 400px; + overflow-y: auto; + } + &font-sized { width: 1rem; height: 1rem; diff --git a/types/melvor/game/combat.d.ts b/types/melvor/game/combat.d.ts index 67bb052..4e23198 100644 --- a/types/melvor/game/combat.d.ts +++ b/types/melvor/game/combat.d.ts @@ -15,12 +15,12 @@ export class CombatSpell extends BaseSpell { } export class AttackStyle extends NamespacedObject { + attackType: AttackTypeID; + experienceGain: Array<{ ratio: number; skill: Skill; }>; - - attackType: AttackTypeID; } export class Attack extends CombatSkill { @@ -41,6 +41,10 @@ export class Enemy extends Character { curse?: any; monster: Monster; + + nextAttack: SpecialAttack; + + queueNextAction(noSpec?: boolean, tickOffset?: boolean): void; } export interface Enemy extends MobLikePartial { @@ -74,7 +78,7 @@ export interface Monster extends MobLikePartial { } export class SpecialAttack extends NamespacedObject { - + get description(): string; } export class CombatPassive extends NamespacedObject { diff --git a/types/melvor/game/core.d.ts b/types/melvor/game/core.d.ts index 625c4a2..c45875f 100644 --- a/types/melvor/game/core.d.ts +++ b/types/melvor/game/core.d.ts @@ -1,4 +1,4 @@ -import {ActivePrayer, CombatSpell} from 'melvor'; +import {ActivePrayer, CombatSpell, SpecialAttack} from 'melvor'; import type {Monster} from './combat'; import {Attack, AttackStyle} from './combat'; import type {EquipmentItem, Item} from './item'; @@ -216,6 +216,8 @@ export class Game { smithing: Smithing; + specialAttacks: NamespaceRegistry; + standardSpells: NamespaceRegistry; summoning: Summoning; diff --git a/types/melvor/global.d.ts b/types/melvor/global.d.ts index b852d61..f22b7d3 100644 --- a/types/melvor/global.d.ts +++ b/types/melvor/global.d.ts @@ -5,6 +5,7 @@ declare const Bank: typeof import('./index').Bank; declare const NamespaceRegistry: typeof import('./index').NamespaceRegistry; declare const AttackTypeID: typeof import('./index').AttackTypeID; declare const CombatManager: typeof import('./index').CombatManager; +declare const Enemy: typeof import('./index').Enemy; declare const NamespacedObject: typeof import('./index').NamespacedObject; declare const EquipmentItem: typeof import('./index').EquipmentItem; declare const SkillWithMastery: typeof import('./index').SkillWithMastery;