rpgjs-solo-renderer
v5.0.0-beta.29.solo.2
Published
Native CanvasEngine renderer and Tiled authoring surface for RPGJS Solo
Readme
rpgjs-solo-renderer
The renderer accepts a game-owned CanvasEngine compatibility installer and runs
it before scene creation. Applications use the public
rpgjs-patches package to centralize
lifecycle workarounds. Each active
map also owns an isolated, keyed viewport and TiledMap element so stale async
tileset loads or retiring camera directives cannot overwrite a newer
destination.
The native rendering and authoring layer for RPGJS Solo. It keeps the RPGJS
Tiled workflow and composes CanvasEngine's scene graph, camera, spritesheets,
and fog-of-war directly around SoloRuntime state.
loadSoloTiledMap derives center-based Solo physics obstacles from collision
tiles on every authored layer. It supports both the collision: true tile
property and RPGJS/Tiled tileset object groups, preserving rectangular or
rotated polygon bounds and thin polylines instead of silently treating the
decorative ground layer as the only collision authority. Contiguous full-tile
collision is coalesced into larger rectangles before physics registration,
while partial and polygonal shapes remain independent.
There is no socket, room, sync, prediction, reconciliation, or game-facing Pixi adapter. Pixi remains an implementation detail of CanvasEngine.
CanvasEngine, its Tiled/preset packages, Pixi, Vite, TypeScript, Vitest, and
declaration tooling are pinned to versions checked as current for each
release; renderer feature-completeness includes that alignment gate.
Release this workspace package with pnpm publish, which rewrites its
workspace: core dependency to the exact published Solo version. The shared
Solo publish guard rejects npm publish, and the workspace-wide packed-manifest
check prevents registry consumers from receiving an unresolved workspace
protocol.
renderer.uiRoot is a framework-neutral DOM overlay. Use the inherited,
current @rpgjs/ui-css primitives there directly or mount the game's chosen UI
framework into it; the renderer does not invent a second widget system.
Keyboard input respects UI ownership: default-prevented events and keys whose
composed path is inside a button, form control, dialog, editable region, or
data-solo-input-owner surface do not leak into movement, interaction, or pause.
import { SoloRuntime } from 'rpgjs-solo'
import { installCanvasEnginePatches } from 'rpgjs-patches'
import {
SoloRenderer,
createSoloTileObstacles,
createRpgMakerSpritesheet,
loadSoloTiledMap,
replaceSoloTiledLayers
} from 'rpgjs-solo-renderer'
const runtime = new SoloRuntime()
const field = await loadSoloTiledMap({ id: 'field', basePath: '/maps' })
runtime.registerMap(field.runtime)
runtime.spawnEntity({ id: 'hero', kind: 'player', mapId: 'field', x: 96, y: 96 })
const renderer = new SoloRenderer({
runtime,
target: document.querySelector('#rpg')!,
playerId: 'hero',
maps: [field],
installCanvasEnginePatches,
appearances: {
hero: {
spritesheet: createRpgMakerSpritesheet('/sprites/hero.png', 3, 4),
animation: (entity) => typeof entity.data.animation === 'string'
? entity.data.animation
: entity.moving ? 'walk' : 'stand'
}
},
camera: {
zoom: ({ width }) => width >= 1200 ? 2.5 : width >= 720 ? 2 : 1.5
},
fog: { radius: 160 },
audio: { autoMuteInTests: true }
})
await renderer.start()The public installer is supplied by the application through the typed injection
interface. Install rpgjs-patches@^0.4.0 from npmjs and inject it as above.
Applications may omit the hook when no compatibility layer is required.
The validated patch matrix is exported as rpgjsSoloRendererCompatibility.
This source cohort requires [email protected], Vite 8.3.3, and consumer-injected
rpgjs-patches@^0.4.0. The installer must run before scene
creation; SoloRenderer guarantees that order when it receives
installCanvasEnginePatches in its options.
import { rpgjsSoloRendererCompatibility } from 'rpgjs-solo-renderer'
console.log(
`${rpgjsSoloRendererCompatibility.patches.package}@${rpgjsSoloRendererCompatibility.patches.version}`
)Story-driven terrain remains a game-owned decision while the renderer owns its safe scene lifecycle. Replace complete GID layers immutably, then register the revision; the active viewport is rebuilt under a new revision key without changing the authoritative Solo map or its entities:
const openedField = replaceSoloTiledLayers(field, [
{ name: 'ground', data: openedGroundGids },
{ name: 'terrain', data: openedTerrainGids }
])
renderer.registerMap(openedField)The helper validates unique layer names, dimensions, and unsigned Tiled GIDs. It never infers physics from visuals. If the story change also affects collision, update the runtime obstacle table from the same game-authored state as shown below.
Games with gates, bridges, or other story-driven traversal changes can derive the replacement obstacle table from the same coalescing path as the TMX loader:
runtime.replaceMapObstacles('field', createSoloTileObstacles({
id: 'field',
width: authoredMap.width,
height: authoredMap.height,
tileWidth: authoredMap.tileSize,
tileHeight: authoredMap.tileSize,
cells: authoredMap.collision
}))camera.zoom may be a fixed positive scale or a resolver over the live canvas
size. Responsive resolvers run again after resizes and orientation changes; the
renderer delegates to pixi-viewport's centered setZoom operation and keeps the
keyed map viewport and player-follow lifecycle intact.
appearance.animation may also be a fixed animation name. Resolver functions
read the authoritative entity state on each runtime tick, so game packages can
map their own attack, hurt, down, interaction, or other states onto a
spritesheet without adding those rules to Solo's generic renderer.
For occlusion-aware and persistable exploration, supply a game-owned visibility snapshot. The renderer samples it in tile coordinates and owns only the display surface; line-of-sight rules and save migrations remain in the game:
fog: {
smooth: false,
visibility: () => ({
mapId: activeMap.id,
width: activeMap.width,
height: activeMap.height,
tileSize: 16,
revision: visibility.revision,
stateAt: (x, y) => visibility.visible.has(y * activeMap.width + x)
? 'visible'
: visibility.discovered.has(y * activeMap.width + x)
? 'explored'
: 'unknown'
})
}