Recipes: changing the game
Things people ask for, each done end to end against the running client. They use the SDK's areas where one covers it and the client itself where none does.
All of them change the game as the player, so they need "game": "act" in the manifest. See Permissions.
Colours everyone sees
The avatar editor shows a ten by five grid of swatches for your animal's first colour, and another for its second. The game has many more colours than that, but the editor is only given a short list.
From gui/ColorTable.as and gui/AvatarEditor.as:
The game has one palette,
PaletteHelper.gamePalette, an array of colours loaded from Animal Jam's image server at startup.Your animal's look is four numbers from 0 to 255, each an index into that palette. They're packed into the three numbers the
apmessage carries.AvatarEditor.onColorChangedputs the first colour in bits 24 to 31 of the first number and the second colour below it. The pattern goes in the second number and the eyes in the third.Every client paints each animal by looking those indexes up in the same palette (
LayerBitmap.refreshPalette), so an index is the same colour on everyone's screen.The editor's grids are the client's own
gui.ColorTable.AvatarEditor.setInitAssetVisibilitybuilds each one with the whole palette and a short list of indexes to show._colorTableColor1.init(0,240,150,10,5,_gamePalette,_avatarPalette1,...,onColorChanged,102)
So for a colour nobody else has, don't paint anything locally. Put more of the game's palette in the game's grid. That's what phantom.aj.player.avatar.colors.offer does.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const palette = await phantom.aj.player.avatar.colors.palette();
console.log('the game knows', palette?.colors.length ?? 0, 'colours');
const offer = await phantom.aj.player.avatar.colors.offer('color1');
console.log('the grid now shows', offer.count);
return () => offer();
},
});The extra swatches are the client's own: its init fills the grid and its fillColorTable draws it. Clicking one goes through ColorTable.colorTableClickHandler to the editor's handler, which packs the index into your colours like any other choice. When you close the editor it sends them with AvatarXtCommManager.requestColorChange (the ap message). Everyone in the room sees the new colour, and it's still there next time you log in.
offer changes the data the grid gets each time it's built, so the colours are still there when you open the editor again. Calling the function it returns undoes it and resets the open grid.
To pick a colour without the player clicking, use pick. It does what a click does: marks the colour, draws the square round it and calls the editor's handler.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const colors = phantom.aj.player.avatar.colors;
await colors.offer('eyes');
await colors.pick('eyes', 200);
await colors.paint({ eyes: 200 });
},
});Only palette colours
Only the index is sent. The ap message carries three numbers holding four palette indexes, so you can't send a colour that isn't in the game's palette, and other players couldn't see it if you did. A colour mixed in the plugin and painted onto the avatar locally only shows on your screen. Don't build that.
Your own rows in a list or grid
The client builds its lists and grids by calling one of its own methods with the data as an argument. gui/ColorTable.as takes the colours and the indexes to draw. gui/WindowAndScrollbarGenerator.as is behind all the scrolling lists and grids in the game, and thirty classes build one. Its init takes the items as its thirteenth argument, initItemList, a plain array. For example:
gui/DenEditor.as, line 683:_itemWindow.init(..., ItemWindowOriginal, _currItems.getCoreArray(), "icon", ...), the strip of den items you drag into the room.buddy/BuddyList.as, line 203:_itemWindowBuddyList.init(..., ItemWindowBuddyList, buildBuddyList(), "", ...), your buddies.gui/RecycleItems.as,gui/TradeManager.as,gui/PetInventory.as,gui/AvatarEditor.asand the rest, each with its own list.
A hook's replaceArgs puts your arguments into that call. The result on screen is still the client's widget, built by its code with its callbacks attached, so a row you add responds to clicks like the others. The arguments are replaced inside the call, in the game, and the client doesn't wait.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const feed = await phantom.flash.hook('gui.ColorTable.init', {
replaceArgs: { colorIndexes: [10, 11, 12, 13, 14, 15], numCols: 3, numRows: 2 },
});
return () => feed();
},
});Don't make a list builder wait
You can override the method instead and decide on each call. The loader then skips the client's method, hands the call to your function and runs the method once it answers. That's only safe for a method the client calls and then moves on from.
The list builder isn't one. gui/AvatarEditor.as rebuilds its clothes grid with _itemClothingWindows.destroy(); _itemClothingWindows = null; createItemWindows(...), more than once while the editor opens. If init is held back, the client destroys a generator whose init never ran. Tiles that finish loading after that are dropped by the if(_mediaWindows && _dynamicScrollbar) check at the top of WindowAndScrollbarGenerator.onInitialWindowsLoaded. The grid comes up empty and nothing throws.
Use replaceArgs, which never makes the client wait.
To add a row, rather than reorder existing ones, give the list something its window class understands. initWindowClass tells you which class that is (gui/itemWindows/ItemWindowOriginal.as for the den editor, ItemWindowBuddyList.as for buddies).
A tile works out its item once, in its constructor, from initCustomParams.indexArray[slot] (gui/itemWindows/ItemWindowOriginal.as), not on each draw. If you reorder initItemList, reorder indexArray the same way in the same call, or the tiles will show one item and act on another.
For a list already on screen, the client has insertItem(obj, updateScrollPosition, insertFromFront), updateItem(index, item) and callUpdateInWindow(). Call them with $call on the typed client wherever the client keeps that WindowAndScrollbarGenerator.
The game's widgets
phantom.aj.ui.place points at any of the client's containers as a typed place. You can read it, drive it and put your own widgets inside it.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const button = await phantom.aj.ui.button('Surprise me', {
skin: 'game',
width: 90,
height: 22,
});
const remove = await phantom.aj.ui.attach('avatarEditor', button);
return () => remove();
},
});ui.place doesn't touch the client until you read or call through it, so you can name a screen that isn't open. ui.places() lists every spot by name, and ui.explore(screen) shows what one of the client's screens is made of while it's open.
Calling the client's own method through a place runs it as the client would, and anything that method sends to Animal Jam is still sent. A value you read out of the client goes back in as the same object, so the client's callbacks and collections survive the round trip.
A membership in your own game
The server decides whether the player is a member and tells the client in two places: the login reply, where GamePlay.onWorldLogIn stores accountType and numDaysLeftOnSubscription, and a membership redeemed during play (GameRedemptionXtCommManager.handleRedeemMembershipResponse). The member parts of the HUD, the name bar and the settings screen all come from those two numbers. phantom.aj.membership.show changes what the game is told and nothing else.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const real = await phantom.aj.membership.real();
console.log(real?.member ? 'a member already' : 'not a member');
await phantom.aj.membership.show({ accountType: 2, daysLeft: -1 });
return () => void phantom.aj.membership.stopShowing();
},
});While it's on, the login reply, a redeemed membership and the player's own avatar (whenever a room describes it) carry what you asked for. If you're already signed in, the game is told straight away, the same way it hears about a redeemed membership. Call stopShowing or turn the plugin off to go back to the real one.
Nothing claiming a membership is sent to Animal Jam. The client sends one thing by itself the first time it thinks the player is a member, zs for user variable 66, and show holds that back for an account that isn't one. The server still checks its own records: members-only rooms, items and adventures are refused as before, and other players see the account as it really is. real and onChange read the server's own messages, so a plugin tracking when a membership runs out isn't fooled by one you're showing.
Staying signed in, and a quieter login
The client tracks idleness in com.sbi.client.KeepAlive. The stage passes every mouse and key press to inputReceivedHandler, which notes the input and, if the last message to the server is over three minutes old, sends the keep-alive (ka) straight away. A timer sends the rest while there's input. With no input the client warns at six and a half minutes and disconnects at seven. Adventures do the same on their own timer in QuestManager.
phantom.aj.session.keepActive counts as input once a minute through both of those handlers. All that reaches Animal Jam is the client's own keep-alive on its own schedule.
To hold a session open with nothing touching the game, phantom.aj.session.keepAlive sends one keep-alive straight away and resets the client's idle countdown. You need both: the server drops a connection it hasn't heard from in seven minutes, and the client has its own watchdog, timed from the last message it sent, that would otherwise disconnect a session the server is happy with.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const active = await phantom.aj.session.keepActive();
const stop = phantom.aj.session.onIdleWarning(() =>
ctx.log('the game thinks we are idle')
);
const skip = await phantom.aj.screens.startup.skipAtLogin([
'newspaper',
'holidayBanner',
]);
const pinned = await phantom.aj.hud.pinCurrency();
return () => {
stop();
void active();
void skip();
void pinned();
};
},
});All three use the client's own behaviour and only affect the player's screen. Skipping a login popup hooks the call that opens it and carries on with the client's own list, so the daily gift and anything else waiting still come up. A skipped banner is still unread as far as the account is concerned.
Each is a timer or hook held by the plugin's frame, so Phantom removes it when the plugin stops, even if the cleanup above didn't run. Reloading a plugin while you work on it doesn't leave the game pinned.