Skip to content

Commit

Permalink
feat: Enemy special attack trigger
Browse files Browse the repository at this point in the history
  • Loading branch information
Alorel committed Dec 9, 2022
1 parent 6090e49 commit aeee900
Show file tree
Hide file tree
Showing 16 changed files with 190 additions and 53 deletions.
16 changes: 16 additions & 0 deletions src/lib/util/eqneq.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const enum EqNeq {
EQ = '=',
NEQ = '!=',
}

type EqNeqFn<T> = (a: T, b: T) => boolean;

const compareFns = new Map<EqNeq, EqNeqFn<any>>([
[EqNeq.EQ, (a, b) => a === b],
[EqNeq.NEQ, (a, b) => a !== b],
]);

/** Get the comparison function for {@link EqNeq} */
export function getEqNeqFn<T>(eqNeq: EqNeq): EqNeqFn<T> {
return compareFns.get(eqNeq)!;
}
7 changes: 5 additions & 2 deletions src/option-types/alt-cost-option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,7 +64,10 @@ defineOption<number, AltRecipeCostNodeOption>({
return null;
}

const ctx: Partial<OptionRenderViewCtx<Item, any>> = {value: altCostItem};
const ctx: Partial<OptionRenderViewCtx<Item, any>> = {
option: EMPTY_OBJ,
value: altCostItem,
};

return h(RenderMediaSelectView, ctx as OptionRenderViewCtx<Item, any>);
},
Expand Down
3 changes: 2 additions & 1 deletion src/option-types/media-item-option.mts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ defineOption<MediaOptionValue, MediaItemNodeOption>({
});

export default function isMediaItemOption(v: NodeOptionBase & Obj<any>): v is MediaItemNodeOption {
const {type, mediaFilter, registry, multi} = v as Partial<MediaItemNodeOption>;
const {type, itemRender, mediaFilter, registry, multi} = v as Partial<MediaItemNodeOption>;

return type === 'MediaItem'
&& isUndefinedOr(mediaFilter, 'function')
&& isUndefinedOr(itemRender, 'function')
&& (
typeof registry === 'function'
|| (
Expand Down
86 changes: 57 additions & 29 deletions src/option-types/media-item/media-edit-base.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,46 +12,56 @@ import Btn from '../../ui/components/btn';
import useTippy from '../../ui/hooks/tippy.mjs';
import {useRenderEditTouch} from '../_common.mjs';

interface Props<T> extends Pick<OptionRenderEditCtx<T, MediaItemNodeOption>, 'onChange' | 'value'> {
interface Props<T> extends
Pick<OptionRenderEditCtx<T, MediaItemNodeOption>, 'onChange' | 'value'>,
Pick<MediaItemNodeOption, 'itemRender' | 'icon'> {

filterFn(filterText: string): T[];
}

const RenderMediaItemOptionOneBase = memo(
function <T extends MediaSelectable> ({filterFn, value, onChange}: Props<T>): VNode {
function <T extends MediaSelectable> ({filterFn, icon, value, onChange, itemRender}: Props<T>): VNode {
const [focus, setFocus] = useState(false);
const changeAndFocus = useCallback((val?: T): void => {
setFocus(true);
onChange(val);
}, [onChange]);

return value
? (<RenderBtn onChange={changeAndFocus} value={value}/>)
: (<RenderFilter focus={focus} onChange={changeAndFocus} filterFn={filterFn}/>);
? (<RenderBtn icon={icon} onChange={changeAndFocus} value={value}/>)
: (
<RenderFilter
itemRender={itemRender}
icon={icon}
focus={focus}
onChange={changeAndFocus}
filterFn={filterFn}/>
);
}
);

export default RenderMediaItemOptionOneBase;

type BtnProps<T> = Required<Pick<Props<T>, 'value' | 'onChange'>>;
type BtnProps<T> = Required<Pick<Props<T>, 'value' | 'onChange'>> & Pick<Props<T>, 'icon'>;

function RenderBtn<T extends MediaSelectable>({value, onChange}: BtnProps<T>): VNode {
function RenderBtn<T extends MediaSelectable>({icon = true, value, onChange}: BtnProps<T>): VNode {
const unselect = useCallback(() => {
onChange(undefined);
}, [onChange]);

return (
<Btn kind={'primary'} size={'sm'} onClick={unselect}>
<img class={'ActionWorkflowsCore-font-sized mr-1'} src={value.media}/>
{icon && <img class={'ActionWorkflowsCore-font-sized mr-1'} src={value.media}/>}
<span>{value.name}</span>
</Btn>
);
}

interface InnerProps<T> extends Pick<Props<T>, 'filterFn' | 'onChange'> {
interface InnerProps<T> extends Pick<Props<T>, 'filterFn' | 'onChange' | 'itemRender' | 'icon'> {
focus: boolean;
}

function RenderFilter<T extends MediaSelectable>({focus, filterFn, onChange}: InnerProps<T>) {
function RenderFilter<T extends MediaSelectable>({focus, filterFn, icon, itemRender, onChange}: InnerProps<T>) {
const filterText = useSignal('');
const results = useComputed((): T[] => {
const txt = filterText.value;
Expand Down Expand Up @@ -88,29 +98,55 @@ function RenderFilter<T extends MediaSelectable>({focus, filterFn, onChange}: In

return (
<div>
<input class={'form-control form-control-sm'}
<input
class={'form-control form-control-sm'}
ref={inputRef}
onKeyUp={onKeyup}
onInput={onInp}
onBlur={onBlur}
placeholder={'Search by name…'}/>
<ItemsRender results={results} onItemClick={onItemClick}/>
<ItemsRender icon={icon} itemRender={itemRender} results={results} onItemClick={onItemClick}/>
</div>
);
}

interface ItemsRenderProps<T> {

/** @default true */
icon?: boolean;

itemRender?: ComponentType<{item: T;}>

results: ReadonlySignal<T[]>;
onItemClick(e: Event): void;
}
const ItemsRender = memo(function <T extends MediaSelectable> ({onItemClick, results}: ItemsRenderProps<T>): VNode {
return (
<div onClick={onItemClick}>
{results.value.map(itemMapper)}
</div>
);
});
(ItemsRender as FunctionComponent).displayName = 'ItemsRender';
function DefaultItemRender<T extends MediaSelectable>({item}: {item: T}): VNode {
return <Fragment>{item.name}</Fragment>;
}
const ItemsRender = memo(
function <T extends MediaSelectable> ({
icon = true,
itemRender: ItemRender = DefaultItemRender,
onItemClick,
results,
}: ItemsRenderProps<T>): VNode {
const classKey = icon ? '' : 'p-1 pl-2 pr-2 list-group-item list-group-item-dark list-group-item-action';

return (
<div onClick={onItemClick} class={icon ? '' : 'list-group ActionWorkflowsCore-list-mh'}>
{results.value.map((item, idx) => (
<a key={item.id} role={'button'} data-idx={idx} class={classKey}>
{
icon
? <ItemImg item={item}/>
: <ItemRender item={item}/>
}
</a>
))}
</div>
);
}
);

/** Focus an element this ref gets attached to on init if `focus` is true */
function useFocus<T extends HTMLElement>(focus: boolean): Ref<T> {
Expand All @@ -124,14 +160,6 @@ function useFocus<T extends HTMLElement>(focus: boolean): Ref<T> {
return ref;
}

function itemMapper<T extends MediaSelectable>(item: T, idx: number): VNode {
return (
<a key={item.id} role={'button'} data-idx={idx}>
<ItemImg item={item}/>
</a>
);
}

function ItemImg<T extends MediaSelectable>({item: {media, name}}: {item: T}): VNode {
const ref = useTippy<HTMLImageElement>(name);

Expand Down
7 changes: 5 additions & 2 deletions src/option-types/media-item/render-media-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ interface EditOneProps<T> extends OptionRenderEditCtx<T, MediaItemNodeOption> {

const RenderEditOne = memo(function <T extends MediaSelectable> ({
onChange,
option: {mediaFilter, registry},
option: {itemRender, icon, mediaFilter, registry},
otherValues,
value,
}: EditOneProps<T>) {
Expand All @@ -118,7 +118,10 @@ const RenderEditOne = memo(function <T extends MediaSelectable> ({
})]), [otherValues, reg, mediaFilter]);

return (
<RenderMediaItemOptionOneBase value={value}
<RenderMediaItemOptionOneBase
itemRender={itemRender}
icon={icon}
value={value}
filterFn={filterFn}
onChange={onChange}/>
);
Expand Down
19 changes: 13 additions & 6 deletions src/option-types/media-item/render-media-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {MediaOptionValue} from '../media-item-option.mjs';
type PartialMedia = Pick<MediaSelectable, 'name' | 'media'>;

const RenderMediaSelectView: OptionDefinition<MediaOptionValue, MediaItemNodeOption>['renderView']
= ({value}) => {
= ({value, option: {icon = true}}) => {
if (Array.isArray(value)) {
const items: RenderNodeMediaProps[] = [];
for (const v of value) {
Expand All @@ -20,9 +20,11 @@ const RenderMediaSelectView: OptionDefinition<MediaOptionValue, MediaItemNodeOpt
}
}

return (<RenderViewMulti items={items}/>);
} else if (isPartialMediaSelectable(value)) {
return (<RenderNodeMedia media={value.media} label={value.name}/>);
return (<RenderViewMulti icon={icon} items={items}/>);
} else if (icon && isPartialMediaSelectable(value)) {
return <RenderNodeMedia media={value.media} label={value.name}/>;
} else if (value?.name) {
return <span>{value.name}</span>;
}

return null;
Expand All @@ -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 => (
<Fragment>
{items.map(item => (
<div key={`${item.media}:${item.label}`}>
<RenderNodeMedia media={item.media} label={item.label}/>
{icon ? <RenderNodeMedia media={item.media} label={item.label}/> : item.label}
</div>
))}
</Fragment>
Expand Down
10 changes: 10 additions & 0 deletions src/public_api/option.d.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ setDefaultLogger(errorLog);

let sidenavIconContainer: Signal<HTMLSpanElement | null>;

ctx.onCharacterLoaded(() => {
sidenavIconContainer = signal<HTMLSpanElement | null>(null);
render(<App sidenavIcon={sidenavIconContainer}/>, document.createElement('div'));
});

ctx.onInterfaceReady(() => {
// Don't start checking triggers for offline time
for (const {def, id} of TRIGGER_REGISTRY.registeredObjects.values()) {
Expand All @@ -27,9 +32,6 @@ ctx.onInterfaceReady(() => {
}
}

sidenavIconContainer = signal<HTMLSpanElement | null>(null);
render(<App sidenavIcon={sidenavIconContainer}/>, document.createElement('div'));

sidebar
.category('')
.item('Action Workflows', {
Expand Down
52 changes: 52 additions & 0 deletions src/triggers/combat/enemy-special-attack-trigger.tsx
Original file line number Diff line number Diff line change
@@ -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<Data>({
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}) => (
<Fragment>
<span class={'font-w600'}>{`${item.name} `}</span>
<small>{item.description}</small>
</Fragment>
),
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,
},
],
});
2 changes: 2 additions & 0 deletions src/triggers/combat/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './enemy-id-trigger.mjs';
import './enemy-special-attack-trigger';
5 changes: 5 additions & 0 deletions src/triggers/core/index.mts
Original file line number Diff line number Diff line change
@@ -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';
8 changes: 2 additions & 6 deletions src/triggers/index.mts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 5 additions & 0 deletions src/ui/assets/styles.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
$colour-err: #e56767;

.ActionWorkflowsCore- {
&list-mh {
max-height: 400px;
overflow-y: auto;
}

&font-sized {
width: 1rem;
height: 1rem;
Expand Down
Loading

0 comments on commit aeee900

Please sign in to comment.