import { searchRank } from '../../ui/search'; import { allNodeDefs, BEHAVIOR_PARAM, createEmptyGraph, isTerminalType, nextStateName, type NodeCategory, type NodeDef, resolveDef, RUN_BEHAVIOR, State, STATE_NAME_PARAM, terminalNode, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { FIND, FIND_MODE } from '../../core/graph/findBlock'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { isParticlePropertyNode, PARTICLE_PROPERTY, PROPERTY_NAME_PARAM as PARTICLE_NAME_PARAM, propertyNameOf, } from '../../core/properties/particles'; import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders'; import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes'; import { SHADER_NODES, SHADER_OUTPUT } from '../../core/shader/nodes'; /** * One row of the palette. `def` is the definition the node would actually have once placed, so the * drop filter and the port it lands on are right for a behavior entry too. */ export interface PaletteEntry { id: string; hint: string; def: NodeDef; params: PropertyBag; } const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action', 'state']; /** * The entries a query answers, best first. How well the entry matches decides before anything else; * category and alphabet only order what is equally good. A node named after the query therefore * beats one that merely mentions it in its description – without that, "variable" + Enter adds * `Run behavior`, whose description happens to contain the word. * * An empty query ranks everything alike, which leaves the plain category order of the palette. */ export function rankedEntries(entries: PaletteEntry[], query: string): PaletteEntry[] { const needle = query.trim().toLowerCase(); return entries .map((entry) => ({ entry, rank: searchRank(needle, [entry.def.label, entry.id, entry.def.description]), })) .filter(({ rank }) => rank >= 0) .sort( (a, b) => a.rank - b.rank || CATEGORY_ORDER.indexOf(a.entry.def.category) - CATEGORY_ORDER.indexOf(b.entry.def.category) || a.entry.def.label.localeCompare(b.entry.def.label), ) .map(({ entry }) => entry); } /** * A Find set to All under a name of its own (#259). Its id is what the search reads besides the * label, so "find" still lands on the plain Find rather than on this one. */ function filterEntry(find: NodeDef): PaletteEntry { const def = { ...find, label: 'Filter', description: 'A Find set to All: its Match hands on every item the test is true for, and their indices.', }; return { id: 'Filter', hint: find.category, def, params: { [FIND_MODE]: 'all' } }; } /** * The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each * new one named by its number; a shader holds shader nodes, its one Output dropping out once * placed. In a behavior, every other behavior appears under its own name instead of behind a * generic "Run behavior", and the two terminals drop out once the graph has them. */ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] { if (asset.type === 'statemachine') { const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) }; return [{ id: State.type, hint: State.category, def: State, params }]; } if (asset.type === 'shader') { const hasOutput = asset.graph.nodes.some((n) => n.type === SHADER_OUTPUT); return SHADER_NODES.filter((def) => def.type !== SHADER_OUTPUT || !hasOutput).map((def) => { const params: PropertyBag = def.type === SHADER_PROPERTY ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } : {}; return { id: def.type, hint: def.category, def, params }; }); } if (asset.type === 'particles') { const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT); const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf)); return PARTICLE_NODES.filter((def) => def.type !== PARTICLE_OUTPUT || !hasOutput).map((def) => { const params: PropertyBag = def.type === PARTICLE_PROPERTY ? { [PARTICLE_NAME_PARAM]: nextParticlePropertyName(taken) } : {}; return { id: def.type, hint: def.category, def, params }; }); } const entries: PaletteEntry[] = []; for (const def of allNodeDefs()) { if (def.type === RUN_BEHAVIOR || def.category === 'state') continue; if (def.category === 'shader' || def.category === 'particles') continue; if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; entries.push({ id: def.type, hint: def.category, def, params: {} }); if (def.type === FIND) entries.push(filterEntry(def)); } const lookup = graphLookup(project); for (const other of project.assets) { // A behavior running itself is an endless loop, so it is not on offer. if (other.type !== 'behavior' || other.id === asset.id) continue; const params = { [BEHAVIOR_PARAM]: other.id }; // The entry is a preview of a node that sits in no graph yet, hence the empty one. const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, createEmptyGraph(), lookup); if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params }); } return entries; }