/** * What a behavior's graph reaches of the running game, as the `NodeContext` its nodes are handed: * the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values. * Handles arriving from a graph are resolved to the world's entities here, so no node holds one. */ import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph'; import { keyName } from '../../core/keys'; import { collisionFilterOf } from '../../core/model/collisionLayers'; import type { Vec2 } from '../../core/model/Properties'; import { JOINT_CONNECTED_KEY } from '../../core/model/joint'; import { objectReference } from '../../core/model/objectRefs'; import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; import type { World } from '../World'; import type { AudioSink } from './Audio'; import type { Instance } from './behaviorPulses'; import type { Input } from './Input'; import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids'; export interface TimeScaled { scale: number; } /** What the running `BehaviorSystem` lends every context it makes. */ export interface ContextSources { project: Project; input: Input; collisions: CollisionSource | null; audio: AudioSink; clock: TimeScaled | null; shared: SharedState; /** A graph moved one of this instance's machines; its events follow between exec flows. */ stateChanged(machineId: string, from: string, to: string): void; } const NO_DYNAMIC_BODY = 'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.'; export function behaviorContext( instance: Instance, world: World, dt: number, run: ContextSources, ): NodeContext { const { entity, states } = instance; const { project, collisions, clock } = run; // Handles arriving from the graph are the entities themselves, or test doubles with the same id. const resolve = (handle: EntityHandle | null): Entity | undefined => handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; return { self: entity, owner: entity.parent, variables: instance.variables ?? entity.props, states, shared: run.shared, stateChanged: (machineId, from, to) => run.stateChanged(machineId, from, to), time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, // By identity, not by id: a restarted scene builds new entities under the same ids. isAlive: (target) => world.entities.some((e) => e === target), findReferenced: (from, ref) => { const found = resolve(from); return (found && world.findReferenced(found, ref)) ?? null; }, childByName: (parent, name) => { const found = resolve(parent); return (found && world.childByName(found, name)) ?? null; }, objects: () => world.entities, isKeyDown: (key) => run.input.isDown(key), wasKeyPressed: (key) => run.input.justPressed.has(keyName(key)), wasKeyReleased: (key) => run.input.justReleased.has(keyName(key)), pointer: world.pointer, hitTest: (target, x, y) => { const found = resolve(target); return Boolean(found && world.hitTest(found, x, y)); }, isTouching: (a, b) => { const ea = resolve(a); const eb = resolve(b); return Boolean(collisions && ea && eb && collisions.touching(world, ea, eb)); }, moveAndSlide: (target, dx, dy) => { const found = resolve(target); if (!found) return NOT_BLOCKED; if (collisions) return collisions.moveAndSlide(world, found, dx, dy); found.x += dx; found.y += dy; return NOT_BLOCKED; }, raycast: (from, direction, length, { ignore, triggers, layers }) => { const hit = collisions?.raycast(world, from, direction, length, { ignore: resolve(ignore) ?? null, triggers, layers, }); world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit)); return hit ? { ...hit, object: bodyOf(hit.collider) } : null; }, raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => { const hits = collisions?.raycastAll( world, from, direction, length, { ignore: resolve(ignore) ?? null, triggers, layers }, maxHits, ) ?? []; // Drawn to the last thing it met, not to the first: that is how far this ray looked. const last = hits[hits.length - 1]; const end = last ? last.point : pointAlong(from, direction, length); world.noteRay(from, end, hits.length > 0); return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) })); }, collisionLayerOf: (target) => { const found = resolve(target); const holder = found && layerHolderOf(world, found); return holder ? collisionFilterOf(holder.props).layer : null; }, ignoreOneWay: (target, seconds) => { const found = resolve(target); if (found) world.ignoreOneWay(found, seconds); }, isTilemap: (target) => { const found = resolve(target); return found ? world.isTilemap(found) : false; }, tileAt: (target, layer, cell) => { const found = resolve(target); return found ? world.tileAt(found, layer, cell) : null; }, setTile: (target, layer, cell, tileId) => { const found = resolve(target); return found ? world.setTile(found, layer, cell, tileId) : false; }, physics: collisions instanceof PhysicsSystem ? physicsControl(world, collisions, resolve) : undefined, log: (message) => { world.log.push(message); // The `Log` node's whole purpose: the author wants this in the browser console. // eslint-disable-next-line no-console console.log(`[behavior ${entity.name}]`, message); }, playSound: (assetId, volume, loop, detached) => playSound(run, assetId, volume, loop, detached ? '' : entity.id), // An id nothing resolves stops nothing, rather than silencing the whole run by accident; // only the empty id means "everything", which is what the node promises. stopSound: (assetId) => { const file = assetId === '' ? '' : soundFileOf(project, assetId); if (assetId === '' || file) run.audio.stop(file); }, spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), addJoint: (assetId, owner, connected, at) => { const on = resolve(owner); if (!on || findAsset(project, assetId)?.type !== 'joint') return null; const joint = world.spawn(assetId, { ...at, z: 0 }, on); const to = resolve(connected); // By id, as a pick in the inspector stores it: the scene view of the run then draws the link too. if (joint && to) joint.props[JOINT_CONNECTED_KEY] = objectReference(to.objectId); return joint; }, destroy: (target, keepChildren) => { const found = resolve(target); if (!found) return []; if (keepChildren) return world.removeKeepingChildren(found); world.remove(found); return []; }, loadScene: (sceneId) => { // An id naming no scene is dropped here rather than half way through the swap, where the // run would already have torn down the world it is still standing in. if (findScene(project, sceneId)) world.loadSceneRequested = sceneId; else world.log.push(`Load scene: no scene ${sceneId}`); }, stop: () => { world.stopRequested = true; }, restart: () => { world.restartRequested = true; }, timeScale: () => clock?.scale ?? 1, setTimeScale: (scale) => { if (clock) clock.scale = scale; else world.log.push(`Set time scale is ignored in the editor run (${scale})`); }, isPaused: () => world.paused, setPaused: (paused) => { world.paused = paused; }, }; } /** * What the graph asked for, as the sink understands it: the asset's own volume scaled by the * node's, and its loop flag unless the node overrules it either way. The asset is what a sound * *is* – a node that says nothing therefore plays it as the project describes it. */ function playSound( { project, audio }: ContextSources, assetId: string, volume: number, loop: boolean | undefined, owner: string, ): void { const asset = findAssetOf(project, assetId, 'sound'); if (!asset?.file) return; const props = resolveAssetProps(project, asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); audio.play(asset.file, level, loop ?? loopsOf(props), owner); } /** Where a ray of that length ends; where it has no direction, where it starts. */ function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 { const d = Math.hypot(direction.x, direction.y); if (!(d > 0) || !(length > 0)) return from; return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length }; } /** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */ function physicsControl( world: World, physics: PhysicsSystem, resolve: (handle: EntityHandle | null) => Entity | undefined, ): PhysicsControl { const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => { const found = resolve(handle); if (!found) return false; if (action(found)) return true; physics.hint(world, found, NO_DYNAMIC_BODY); return false; }; return { velocityOf: (handle) => { const found = resolve(handle); const v = found ? physics.velocityOf(found) : null; return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null; }, setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })), setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)), applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })), setGravity: (x, y) => physics.setGravity(world, { x, y }), }; }