Create a Plugin
This example creates a TNT plugin that demonstrates custom blocks, commands, systems, and keybindings. Place TNT and rocks on the canvas, then press Space or click the Explode button to blow up the TNT and any nearby rocks.
<script setup lang="ts">import { WovenCanvas, SelectTool, HandTool, Toolbar } from '@woven-canvas/vue'import '@woven-canvas/vue/style.css'import { TntPlugin, Tnt, Rock } from './TntPlugin'import TntBlock from './TntBlock.vue'import RockBlock from './RockBlock.vue'import TntTool from './TntTool.vue'import RockTool from './RockTool.vue'import ExplodeButton from './ExplodeButton.vue'
// Block definitions for TNT and Rockconst blockDefs = [ { tag: 'tnt', components: [Tnt], }, { tag: 'rock', components: [Rock], },]</script>
<template> <WovenCanvas :editor="{ plugins: [TntPlugin], blockDefs }"> <!-- Toolbar with placement tools --> <template #toolbar> <Toolbar> <SelectTool /> <HandTool /> <TntTool /> <RockTool /> </Toolbar> </template>
<!-- Block renderers --> <template #block:tnt="props"> <TntBlock v-bind="props" /> </template>
<template #block:rock="props"> <RockBlock v-bind="props" /> </template>
<!-- External UI: Explode button --> <template #default> <div class="ui-overlay"> <ExplodeButton /> <div class="hint">Press Space to explode</div> </div> </template> </WovenCanvas></template>
<style scoped>.ui-overlay { position: absolute; top: 16px; right: 16px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; z-index: 100; pointer-events: auto;}
.hint { font-size: 12px; color: var(--wov-gray-400); background: var(--wov-gray-800); padding: 4px 8px; border-radius: 4px;}</style>import type { Context, EditorPlugin } from '@woven-canvas/core'import { Block, defineCommand, defineEditorSystem, defineQuery, field, intersectAabb, Key, on, removeEntity,} from '@woven-canvas/core'import type { Aabb } from '@woven-canvas/math'import { defineCanvasComponent } from '@woven-canvas/vue'
// ============================================================================// Components// ============================================================================
/** * Marker component for TNT blocks. */export const Tnt = defineCanvasComponent( { name: 'tnt', sync: 'document' }, { // Could add properties like fuse length, blast power, etc. blastRadius: field.float64().default(100), },)
/** * Marker component for Rock blocks. */export const Rock = defineCanvasComponent( { name: 'rock', sync: 'document' }, { // Could add properties like hardness, size, etc. hardness: field.float64().default(1), },)
// ============================================================================// Commands// ============================================================================
/** * Command to trigger TNT explosion. * Spawned by the Explode button or Space keybind. */export const Explode = defineCommand<void>('explode')
// ============================================================================// Queries// ============================================================================
const tntQuery = defineQuery((q) => q.with(Block, Tnt))const rockQuery = defineQuery((q) => q.with(Block, Rock))
// ============================================================================// Systems// ============================================================================
/** * Handle TNT explosion: * 1. Find all TNT blocks * 2. For each TNT, find all rocks within blast radius * 3. Delete the TNT and nearby rocks */function handleExplode(ctx: Context): void { // Get all TNT blocks const tntEntities = tntQuery.current(ctx) if (tntEntities.length === 0) return
// Get all rock blocks const rockEntities = rockQuery.current(ctx)
const toDelete = new Set<number>()
for (const tntId of tntEntities) { // Get TNT position and blast radius const tnt = Tnt.read(ctx, tntId) const center = Block.getCenter(ctx, tntId) const radius = tnt.blastRadius
// Create blast area AABB const blastBounds: Aabb = [center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius]
// Find all rocks in blast radius const nearbyEntities = intersectAabb(ctx, blastBounds, rockEntities)
for (const rockId of nearbyEntities) { toDelete.add(rockId) }
// Mark TNT for deletion toDelete.add(tntId) }
// Remove all affected entities for (const id of toDelete) { removeEntity(ctx, id) }}
/** * System that handles TNT explosions. */const explodeSystem = defineEditorSystem({ phase: 'update' }, (ctx: Context) => { on(ctx, Explode, handleExplode)})
// ============================================================================// Keybinds// ============================================================================
const keybinds = [ { command: Explode.name, key: Key.Space, },]
// ============================================================================// Plugin Export// ============================================================================
/** * TNT Plugin - Demonstrates custom blocks, commands, systems, and keybinds. */export const TntPlugin: EditorPlugin = { name: 'tnt', components: [Tnt, Rock], systems: [explodeSystem], keybinds,}<script setup lang="ts">import { computed } from 'vue'import { useComponent } from '@woven-canvas/vue'import { Block } from '@woven-canvas/core'import { Tnt } from './TntPlugin'
const props = defineProps<{ entityId: number selected: boolean}>()
const block = useComponent(props.entityId, Block)const tnt = useComponent(props.entityId, Tnt)
// Calculate blast radius circle dimensionsconst blastRadiusStyle = computed(() => { if (!block.value || !tnt.value) return {}
const [width, height] = block.value.size const radius = tnt.value.blastRadius const diameter = radius * 2
// Center the circle on the block const offsetX = (diameter - width) / 2 const offsetY = (diameter - height) / 2
return { width: `${diameter}px`, height: `${diameter}px`, left: `-${offsetX}px`, top: `-${offsetY}px`, }})
const containerStyle = computed(() => ({ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', containerType: 'size' as const, filter: props.selected ? 'drop-shadow(0 0 8px rgba(239, 68, 68, 0.7))' : 'none', transition: 'filter 0.15s ease',}))</script>
<template> <div :style="containerStyle"> <div class="blast-radius" :style="blastRadiusStyle" /> <span class="emoji">🧨</span> </div></template>
<style scoped>.emoji { font-size: 90cqmin; line-height: 1; user-select: none;}
.blast-radius { position: absolute; border: 2px dashed rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.05); pointer-events: none;}</style><script setup lang="ts">import { computed } from 'vue'
const props = defineProps<{ entityId: number selected: boolean}>()
const containerStyle = computed(() => ({ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', containerType: 'size' as const, filter: props.selected ? 'drop-shadow(0 0 8px rgba(107, 114, 128, 0.7))' : 'none', transition: 'filter 0.15s ease',}))</script>
<template> <div :style="containerStyle"> <span class="emoji">🪨</span> </div></template>
<style scoped>.emoji { font-size: 90cqmin; line-height: 1; user-select: none;}</style><script setup lang="ts">import { ToolbarButton } from '@woven-canvas/vue'
// Snapshot defines what gets created when the tool is usedconst snapshot = JSON.stringify({ block: { tag: 'tnt', size: [80, 80], }, tnt: { blastRadius: 100, },})</script>
<template> <ToolbarButton name="tnt" tooltip="TNT (Space to explode)" :placement-snapshot="snapshot" :drag-out-snapshot="snapshot"> <span style="font-size: 20px; line-height: 1">🧨</span> </ToolbarButton></template><script setup lang="ts">import { ToolbarButton } from '@woven-canvas/vue'
// Snapshot defines what gets created when the tool is usedconst snapshot = JSON.stringify({ block: { tag: 'rock', size: [70, 70], }, rock: { hardness: 1, },})</script>
<template> <ToolbarButton name="rock" tooltip="Rock" :placement-snapshot="snapshot" :drag-out-snapshot="snapshot"> <span style="font-size: 20px; line-height: 1">🪨</span> </ToolbarButton></template><script setup lang="ts">import { computed } from 'vue'import { Block } from '@woven-canvas/core'import { useEditorContext, useQuery } from '@woven-canvas/vue'import { Tnt, Explode } from './TntPlugin'
// Query all TNT blocks on the canvasconst tntBlocks = useQuery([Block, Tnt] as const)
// Button is disabled when no TNT existsconst isDisabled = computed(() => tntBlocks.value.length === 0)
// Get editor context to spawn commandsconst { getEditor } = useEditorContext()
function handleExplode() { const editor = getEditor() if (!editor || isDisabled.value) return
// Spawn the Explode command editor.command(Explode)}</script>
<template> <button class="explode-button" :class="{ disabled: isDisabled }" :disabled="isDisabled" @click="handleExplode"> <span class="icon">💥</span> <span class="label">Explode</span> <span v-if="!isDisabled" class="count">({{ tntBlocks.length }})</span> </button></template>
<style scoped>.explode-button { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);}
.explode-button:hover:not(.disabled) { background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);}
.explode-button:active:not(.disabled) { transform: translateY(0); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);}
.explode-button.disabled { background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); cursor: not-allowed; opacity: 0.7;}
.icon { font-size: 16px;}
.label { text-transform: uppercase; letter-spacing: 0.5px;}
.count { font-size: 12px; opacity: 0.9;}</style>How It Works
Section titled “How It Works”TntPlugin.ts — The Plugin
Section titled “TntPlugin.ts — The Plugin”Defines the complete plugin with:
- Components:
TntandRockmarker components to identify block types - Command:
Explodecommand that can be spawned from UI or keybinds - System:
explodeSystemruns in the update phase, queries for TNT blocks, finds nearby rocks usingintersectAabb, and deletes them - Keybind: Binds Space to the “explode” command
The system demonstrates spatial queries using intersectAabb to find all rocks within a blast radius around each TNT block.
TntBlock.vue & RockBlock.vue — Block Renderers
Section titled “TntBlock.vue & RockBlock.vue — Block Renderers”Simple block renderers that display TNT and rocks. TntBlock.vue uses useComponent() to reactively read block and TNT data (including blast radius), while RockBlock.vue is a lightweight visual renderer.
TntTool.vue & RockTool.vue — Toolbar Buttons
Section titled “TntTool.vue & RockTool.vue — Toolbar Buttons”Toolbar buttons that use placement-snapshot to create blocks with the appropriate tag and components when clicked or dragged.
ExplodeButton.vue — External UI
Section titled “ExplodeButton.vue — External UI”A button outside the canvas that:
- Uses
useQueryto track all TNT blocks on the canvas - Disables itself when no TNT exists
- Spawns the
Explodecommand viaeditor.command()when clicked
App.vue — Integration
Section titled “App.vue — Integration”Registers the plugin with the editor:
- Passes
TntPluginto the editor’spluginsoption - Sets up block definitions with tags and components
- Renders the toolbar, block slots, and external UI
Key Concepts
Section titled “Key Concepts”Commands
Section titled “Commands”Commands are ephemeral entities that exist for one frame. External code spawns them, and systems react to them:
import { defineCommand, on } from '@woven-canvas/core'
// Define a commandconst Explode = defineCommand<void>('explode')
// Spawn from UIeditor.command(Explode)
// React in a system using on()on(ctx, Explode, (ctx) => { // Handle explosion})Keybinds
Section titled “Keybinds”Keybinds map key combinations to commands:
import { Key } from '@woven-canvas/core'
const keybinds = [ { command: 'explode', key: Key.Space }]Custom Components
Section titled “Custom Components”Define data that gets synced with the document using defineCanvasComponent:
import { field } from '@woven-canvas/core'import { defineCanvasComponent } from '@woven-canvas/vue'
export const Tnt = defineCanvasComponent( { name: 'tnt', sync: 'document' }, { blastRadius: field.float64().default(100), },)Systems
Section titled “Systems”Systems run every frame in a specific phase. Use on() to react to commands:
import { defineEditorSystem, on, type Context } from '@woven-canvas/core'
const explodeSystem = defineEditorSystem( { phase: 'update' }, (ctx: Context) => { on(ctx, Explode, handleExplode) })Queries
Section titled “Queries”Queries efficiently find entities with specific components:
import { defineQuery, Block } from '@woven-canvas/core'
const tntQuery = defineQuery((q) => q.with(Block, Tnt))
// In a systemfor (const entityId of tntQuery.current(ctx)) { const tnt = Tnt.read(ctx, entityId)}Bounding Box
Section titled “Bounding Box”Find entities within a bounding box using intersectAabb:
import { intersectAabb } from '@woven-canvas/core'import type { Aabb } from '@woven-canvas/math'
const blastBounds: Aabb = [ center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius]const nearby = intersectAabb(ctx, blastBounds, rockEntities)useComponent
Section titled “useComponent”Reactively read ECS component data in Vue:
import { useComponent } from '@woven-canvas/vue'
const tnt = useComponent(props.entityId, Tnt)
// Access reactivelyconst radius = tnt.value?.blastRadiusRelated
Section titled “Related”- Create a Custom Block — Block definition basics
- Editor API — Using editor.command() and editor.nextTick()