Core concepts
The rules on this page hold across the whole API. The rest of the guide refers back to them instead of repeating them.
Units, players and ids#
- Tiles and pixels. Terrain, fog and rectangles count tiles. Units, sprites and
locations are placed in map pixels, 32 to a tile (
api.consts.tile). A rectangle is{ x0, y0, x1, y1 }in tiles, withx1andy1exclusive. - Players count from 0.
owner0 is Player 1, as in the map file.api.names.player(slot)gives the name the editor shows. - Ids are the game's. A unit id is its row in
units.dat(0 is the Terran Marine), and upgrades and technologies likewise.api.namesturns any of them into a name, andapi.constshas the ones the map file treats specially, so a plugin does not hard-code them.
Asynchronous calls, and the one synchronous builder#
Asynchronous calls return promises. There are no completion callbacks. When the user
dismisses something (Esc, Cancel, a right-click, the ×), the promise resolves with null
or false instead of rejecting, so the normal path needs no try. This covers opening,
saving and exporting maps, loading game data, and everything that waits for the user
(pickers, confirm, prompt and so on).
const rect = await api.ui.pickArea({ prompt: "Pick an area to flatten" });
if (rect) { // null: Esc, a right-click, or no map
await api.tileset.load(); // the graphics the fill needs
const [, ground] = api.terrain.types(); // the tileset's second flat terrain
if (ground) api.document.edit("Flatten", (tx) => tx.stampTerrain(rect, ground.id));
else api.ui.status("The tileset graphics are not installed.");
}
Try itThe remaining callbacks are real callbacks: event listeners, widget handlers, a dialog's
mount, and the pointer and draw hooks of map tools and overlays.
A transaction's function must be synchronous. document.edit(label, build) and
document.update(label, build) commit the moment build returns. An async function
would commit at its first await, and the rest would change the map outside the undo
entry. TypeScript refuses one, and the editor catches it at run time too.
Wrong, because the edit commits at the await and the placement lands outside it:
api.document.edit("Place", async (tx) => {
await api.data.load();
tx.placeUnit(0, 0, 128, 128);
});
Right: await first, then write.
await api.data.load();
api.document.edit("Place", (tx) => { tx.placeUnit(0, 0, 128, 128); });
Try itFor long work, api.ui.progress shows a panel
over the map that does not block editing. Check cancelled() in your loop. Its signal
is an AbortSignal, so a fetch(url, { signal: job.signal }) stops when the user
cancels.
The three kinds of write#
Every change a plugin makes to the map goes through one of three calls, matching the editor's own three ways of writing: a brush stroke, a dialog's OK, and a raw file edit.
| Call | Covers | Undo | If your function throws |
|---|---|---|---|
document.edit(label, build) |
Terrain and objects: tiles, ISOM, units, sprites, doodads, locations, fog. | One history entry. | Rolled back. |
document.update(label, build) |
Tables and settings: triggers, briefing, strings, switch names, name and description, players, forces, unit / upgrade / tech settings, sounds, map revision. | One history entry, as with a settings dialog's OK. | What was written stays, as one entry. |
document.sections.* |
The file's raw bytes, any section. | None, and the history is cleared. | Nothing is written. |
In both transactions each operation applies immediately, so a later one sees what an earlier one did. Calling the transaction after the function has returned throws. The transaction marks the map modified, saves the right file sections and repaints; you do none of that yourself.
The map you read is read-only. document.scenario() is the editor's own live object,
typed read-only all the way down: a line that writes to it does not compile, because a
direct write would skip undo, never be saved and never repaint. Copy what you want to
change ({ ...scn.units[0], x: 64 } is an ordinary record) and hand it to a transaction.
A helper of your own that only reads takes ReadonlyScenario, or readonly UnitRecord[]
for one of its lists.
What you add is removed for you#
Menu items, hotkeys, commands, services, dialogs, panels, overlays, map tools and listeners are all tracked. Turning the plugin off, reloading it or removing it takes them away whether or not you cleaned up.
Disposables. Everything
add,on,registerandprovidereturn is aDisposable. Keep the ones you want to remove early and ignore the rest.Cleanup functions. A dialog's or panel's
mountmay return a function, which runs when it closes. Use it for listeners that should stop with the panel rather than with the plugin.What the API cannot see. Timers, sockets and workers are yours to stop. Return a function from
activateand the editor calls it when the plugin is turned off:import type { PluginApi } from "@scm-js/plugin-api"; export default function activate(api: PluginApi) { const timer = setInterval(() => api.log("still running"), 60_000); return () => clearInterval(timer); }Late writes are refused. Once a plugin is turned off, anything it left behind can no longer change the map: writes do nothing and log a line in the Debug Console.
A part you remove as a whole.
api.scope()gives a childapiwhose registrations go together, for a mode the user turns on and off.
When no map is open#
The editor can have no map open, and a plugin starts before the first map does.
- Reads answer
null,[]orfalseinstead of throwing. The exception is the raw-bytes path,document.sections, whosefile()andbytes()throw. - Writes change nothing.
editandupdatereturn a result withchanged: false. - Menu items should say so. Give an item
enabled: () => api.document.isOpen()and it is greyed out until a map is open.
The game's files may be missing too: the editor works without them, so anything that needs
the tileset, the unit graphics or the game's tables degrades. A terrain operation writes
nothing and says so in its result, api.data answers null, and api.graphics returns no
picture. Check what you get back, and show a name where a picture is missing.
Several maps, and maps to look at only#
Several maps can be open at once, as tabs. Every call works on the map in front.
document.id()is the map in front,document.list()every open map, anddocument.activate(id)brings one to the front. Only switch maps when the user asked for it.document.openanddocument.createtakeinto: "new"(beside the open map) orinto: "current"(in its place, through the unsaved-changes question). Left out, the user's preference decides.- Key anything you keep per map on
id(), and drop entrieslist()no longer has:
const notes = new Map<number, string[]>(); // per open map, by its id
api.events.on("document", (e) => {
const open = new Set(api.document.list().map((map) => map.id));
for (const id of notes.keys()) if (!open.has(id)) notes.delete(id);
if (e.id !== null && !notes.has(e.id)) notes.set(e.id, []);
});
Try itView-only maps. document.open(bytes, name, { viewOnly: { label: "Replay" } }) opens
a map to look at, not to change, such as a replay's map or a preview from a website. It
always opens beside the others, and its tab and the status bar show the label. On such a
map:
- Panning, zooming, the layers and selecting work. The editing tools, menus and dialogs are off.
- Every write throws with the reason:
edit,update,sectionswrites,resize,changeTileset,extras, the clipboard'scutandpaste,saveandsaveAsother than as a copy, andsync.start. - It is not kept in Open Recent, and closing it asks nothing.
document.info()?.viewOnlyanddocument.list()say which maps are like this. File ▸ Edit a Copy gives the user an ordinary copy to change.
Events#
api.events.on(event, fn) returns a Disposable. Listeners run after the change, in
plugin activation order, and cannot block or change it. A listener that rewrites the map
(Repair does) causes a new "document" event that every listener sees.
| Event | When |
|---|---|
"document" |
The map in front changed. reason is "open", "new", "close", "replace" (reparsed after a raw edit) or "switch" (another open map came to the front). Carries the map's id. |
"commit" |
One change was committed: its reason, label, the tile area it touched, and which parts changed. For following changes one by one, as Timelapse does. |
"terrain" |
Any committed map edit, undo or redo. |
"units", "doodads", "sprites", "locations" |
That list changed. |
"settings", "triggers" |
A settings dialog's OK; the trigger or briefing list. |
"layer", "selection", "clipboard" |
The active layer, the selection, the marked area or clip. |
"view", "tool" |
The view moved or its options changed; a map tool or pick started or stopped. |
"palette", "options" |
A palette's pick; an editing option or preference. |
"modified", "file" |
The unsaved-changes flag; the file's name, save options, archive files or recent list. |
"commands", "services" |
A plugin added or removed a command or service. |
"dialogs" |
A dialog opened or closed. |
"gameData", "language" |
The game data or the editor's language changed. |
Only "document" and "commit" carry a payload. The others say that something changed,
and you read the new state from the API. A plugin that acts on maps as they open listens
for "document" with reason "open":
api.events.on("document", (e) => {
if (e.reason === "open") api.ui.status(`Opened ${e.fileName ?? "a map"}`);
});
Try itThe reference has an example of
"commit".