Shortcuts, moving and the view
Three areas of the SDK are involved. phantom.aj.input gives your plugin keyboard shortcuts and the mouse. phantom.aj.player.avatar walks the animal and turns the room's walls off. phantom.aj.view zooms the room and hides the game's interface, the player's avatar or their name tag.
Nothing in phantom.aj.view is sent to Animal Jam; it only changes what this screen draws. Walking is different. Every step is sent as a normal move, and the room sees the animal walk the same as it would with the arrow keys.
Keyboard shortcuts
Bind a shortcut with a function to run when it's pressed. Keys are named the way phantom.flash.onKey names them: 'H', 'F2', 'Shift+=', 'Ctrl+Shift+H'. bind returns a function that removes the shortcut.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
let shown: (() => Promise<void>) | null = null;
const shortcut = await phantom.aj.input.bind('Shift+H', async () => {
if (shown) {
await shown();
shown = null;
} else {
shown = await phantom.aj.view.hide('interface');
}
});
return () => {
void shortcut();
void shown?.();
};
},
});Shortcuts are built around Animal Jam's chat bar. The client gives the bar keyboard focus when a room loads, after every message sent and on every click on the room, so it has focus nearly all the time. A shortcut that waited for nothing to be focused would never fire. Instead, a shortcut fires while the chat bar is empty and stops as soon as the player starts typing, so they can type "wow" without your W firing on the second letter.
The game still gets the key unless you bind it with consume: true, which also keeps the letter (and repeats of a held key) out of the chat bar. That's what a movement key wants. The downside is that a consumed key can't start a message: with W bound and consumed, the player can't start a message with "w". Mention it in your plugin's description and let the player turn it off.
Each press has consumed, which says whether the game was actually kept from the key. It's false if you didn't ask for it, and false if the game is running a loader from before Phantom could keep a character out of a text field. If your shortcut fires and the letter still shows up in the chat bar, you can log that instead of leaving the player guessing.
Shortcuts belong to the game, not your plugin. onShortcut hears every shortcut bound through the area, so check shortcut before acting on it. Phantom removes your plugin's shortcuts when it stops.
Keys that work while typing
Sometimes a key needs to work while the player is typing. whileTyping: 'always' makes the shortcut fire whatever has the keyboard, including a half-written message.
await phantom.aj.input.bind('F2', () => console.log('F2'), { whileTyping: 'always' });Don't use a letter for this. The shortcut would fire every time the player types that letter, and with consume as well, the letter never reaches the chat bar and messages can't be written at all. Use Escape, a function key, or a combination with Ctrl or Alt.
Letters that walk
A movement letter like W needs the opposite: fire and stay out of the chat bar while the player isn't typing, and act as a normal letter once they are. Use yieldToTyping: true. As well as going quiet once there's text in the chat bar, the key goes quiet as soon as the player clicks into the bar, even while it's empty, so a message can start with that letter. Escape, Enter or a click anywhere else gives it back. A key still held when typing starts is released, so your release listener hears it, and its repeats stay out of the message. The loader decides this on each press, so nothing waits on your plugin.
await phantom.aj.input.bind('W', ({ phase }) => console.log('W', phase), {
release: true,
anyModifiers: true,
consume: true,
yieldToTyping: true,
});phantom.aj.input.bound() lists what's bound and how each is heard, including its whileTyping rule. Show this if your plugin lets the player see its keys.
Any key
bind is for a key you know in advance. To find out what the player just pressed, for example when asking them to choose a key, phantom.aj.input.onAnyKey hears every key with one listener and nothing bound.
const stop = phantom.aj.input.onAnyKey(({ code }) => {
console.log('pressed', code);
});It follows the same focus rule as bind, so it stops while a message is being typed and never reads what the player writes. Nothing is kept from the game, and only presses are reported. For a release, bind the key with release.
The mouse
phantom.aj.input hears the mouse from the room's own handlers, so you don't have to keep asking where the pointer is.
onClick and onRelease hook the room's stage handlers (RoomManagerView.onMouseDownEvt and onMouseUpEvt), so they report presses the game acted on, with the point, the modifiers held and the name of whatever was under the pointer. The name is read inside the call, because a press that closes its own window leaves nothing to read a moment later.
const stop = phantom.aj.input.onClick(({ x, y, shift, target }) => {
console.log(shift ? 'shift-clicked' : 'clicked', x, y, target ?? 'the room');
});The pointer isn't hooked. Flash fires a move event for every pixel, which would be hundreds of messages a second, so onPointer watches the room's fields inside the game every few frames and sends nothing while the pointer is still.
const stop = phantom.aj.input.onPointer(
({ x, y, held }) => {
if (held) console.log('dragging over', x, y);
},
{ everyFrames: 6 }
);pointer() reads the same thing once. pointerInRoom() converts it to room coordinates with the client's own conversion, which is what you compare with an avatar's position or a walk grid cell.
onClick only hears the room, because the room's handler returns early while a window is modal or the screen is darkened. To hear any input anywhere, onAnyInput hooks the client's idle watchdog (KeepAlive.inputReceivedHandler), which gets every mouse and key press in the game. It only carries Flash's name for the event. Note that the client sends its own keep-alive from inside that handler when its last message to Animal Jam is over three minutes old.
All of this needs "game": "read".
Walking with the keys
phantom.aj.player.avatar.glide walks the animal steadily in a direction, like holding an arrow key, until you stop it. Bind each key with release: true so you hear it go up as well as down, and anyModifiers: true so holding Shift doesn't stop the walk.
import { definePlugin } from '@phantomclient/plugin';
const KEYS: Record<string, { x: number; y: number }> = {
W: { x: 0, y: -1 },
A: { x: -1, y: 0 },
S: { x: 0, y: 1 },
D: { x: 1, y: 0 },
};
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const held = new Set<string>();
const steer = ({ shortcut, phase }: { shortcut: string; phase: 'down' | 'up' }) => {
if (phase === 'down') held.add(shortcut);
else held.delete(shortcut);
const x = [...held].reduce((sum, key) => sum + KEYS[key]!.x, 0);
const y = [...held].reduce((sum, key) => sum + KEYS[key]!.y, 0);
void phantom.aj.player.avatar.glide(x || y ? { x, y } : null);
};
const keys: Array<() => Promise<void>> = [];
for (const key of Object.keys(KEYS)) {
keys.push(
await phantom.aj.input.bind(key, steer, {
release: true,
anyModifiers: true,
consume: true,
})
);
}
return () => {
for (const key of keys) void key();
void phantom.aj.player.avatar.glide(null);
};
},
});You need consume: true here. Without it, W walks the animal and types "wwww" into the chat bar, because the bar has focus. A key you heard go down is always heard going up, even if the player started a message in between, so the animal never walks off on its own. If your plugin stops mid-walk, Phantom stops the animal.
Walking through walls
phantom.aj.player.avatar.noClip() makes the room's walk grid report every cell inside the room as open, and empties the cells the client's path planner reads. The keys, glide and a click on the far side of a wall all go straight there. Cells outside the grid stay closed, so the animal can't walk off the edge of the room and leave the camera behind. Adventures aren't affected, because their creatures and arrows use the same grid. Call the function noClip() returns and the client reads the room's own grid again, with the walls exactly as they were.
phantom.aj.player.avatar.isNoClip() says whether it's on, and phantom.aj.player.avatar.onNoClip hears it change. Phantom turns it off when your plugin stops.
Zooming the room
phantom.aj.view.keepZoom(percent) zooms the room on this screen and keeps it through the client's own resizes. 100 is the game's normal size and lower zooms out. The client won't zoom out so far that a small room stops filling the game. The function it returns puts the client's zoom back. phantom.aj.room.camera.zoom(percent) zooms once, until the client zooms again.
Clean screenshots
phantom.aj.view.hide('interface') hides the HUD and every window and popup the game draws. It stays hidden until you call the function it returns. hide('ownAvatar') and hide('ownNameTag') hide the player's own animal or just the name over it, and stay hidden from room to room. Other players still see them.
phantom.aj.view.onChange hears all of it: the zoom, whoever changed it, and what's hidden. Anything your plugin hides comes back when it stops.
phantom.aj.view.clean() hides all three at once, and calling the function it returns puts back exactly what it hid. Anything the player had already hidden stays hidden, so you don't need to read the state first.
const restore = await phantom.aj.view.clean();
await sleep(200);
await restore();Pass { ownNameTag: false } and so on to leave one of them alone.
See the reference for the view, keyboard shortcuts and you and your animal.