Skip to content

Commit d7f2b7f

Browse files
marcteboclaudeflorintimbuc
authored
feat: add support for external asset packs 🎨 (#169)
* feat: add support for external asset packs Persist and load furniture assets from user-defined directories outside the extension, enabling third-party asset packs to be used alongside built-in furniture. - Add configPersistence.ts to read/write ~/.pixel-agents/config.json - Load external asset dirs on boot and merge with bundled assets - Add/remove directories via Settings modal with live palette refresh - Add docs/external-assets.md covering the manifest format and usage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Windows path display, asset ID dedup, path traversal defense in external assets --------- Co-authored-by: Marc teBoekhorst <marctebo@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Florin Timbuc <florin@sowild.design>
1 parent 04edcbe commit d7f2b7f

13 files changed

Lines changed: 1570 additions & 215 deletions

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ Thumbs.db
2828
# Project-specific
2929
.claude/
3030
/sprites-export
31+
32+
# Local notes (not for commit)
33+
/notes/

‎CLAUDE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ src/ — Extension backend (Node.js, VS Code API)
1111
PixelAgentsViewProvider.ts — WebviewViewProvider, message dispatch, asset loading
1212
assetLoader.ts — PNG parsing, sprite conversion, catalog building, default layout loading
1313
agentManager.ts — Terminal lifecycle: launch, remove, restore, persist
14+
configPersistence.ts — User-level config file I/O (~/.pixel-agents/config.json), external asset directories
1415
layoutPersistence.ts — User-level layout file I/O (~/.pixel-agents/layout.json), migration, cross-window watching
1516
fileWatcher.ts — fs.watch + polling, readNewLines, /clear detection, terminal adoption
1617
transcriptParser.ts — JSONL parsing: tool_use/tool_result → webview messages
@@ -73,7 +74,7 @@ scripts/ — 7-stage asset extraction pipeline
7374

7475
**Vocabulary**: Terminal = VS Code terminal running Claude. Session = JSONL conversation file. Agent = webview character bound 1:1 to a terminal.
7576

76-
**Extension ↔ Webview**: `postMessage` protocol. Key messages: `openClaude`, `agentCreated/Closed`, `focusAgent`, `agentToolStart/Done/Clear`, `agentStatus`, `existingAgents`, `layoutLoaded`, `furnitureAssetsLoaded`, `floorTilesLoaded`, `wallTilesLoaded`, `saveLayout`, `saveAgentSeats`, `exportLayout`, `importLayout`, `settingsLoaded`, `setSoundEnabled`.
77+
**Extension ↔ Webview**: `postMessage` protocol. Key messages: `openClaude`, `agentCreated/Closed`, `focusAgent`, `agentToolStart/Done/Clear`, `agentStatus`, `existingAgents`, `layoutLoaded`, `furnitureAssetsLoaded`, `floorTilesLoaded`, `wallTilesLoaded`, `saveLayout`, `saveAgentSeats`, `exportLayout`, `importLayout`, `settingsLoaded` (includes `externalAssetDirectories`), `setSoundEnabled`, `addExternalAssetDirectory`, `removeExternalAssetDirectory` (field: `path`), `externalAssetDirectoriesUpdated` (field: `dirs`).
7778

7879
**One-agent-per-terminal**: Each "+ Agent" click → new terminal (`claude --session-id <uuid>`) → immediate agent creation → 1s poll for `<uuid>.jsonl` → file watching starts.
7980

@@ -89,7 +90,7 @@ JSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Pro
8990

9091
**Extension state per agent**: `id, terminalRef, projectDir, jsonlFile, fileOffset, lineBuffer, activeToolIds, activeToolStatuses, activeSubagentToolNames, isWaiting`.
9192

92-
**Persistence**: Agents persisted to `workspaceState` key `'pixel-agents.agents'` (includes palette/hueShift/seatId). **Layout persisted to `~/.pixel-agents/layout.json`** (user-level, shared across all VS Code windows/workspaces). `layoutPersistence.ts` handles all file I/O: `readLayoutFromFile()`, `writeLayoutToFile()` (atomic via `.tmp` + rename), `migrateAndLoadLayout()` (checks file → migrates old workspace state → falls back to bundled default), `watchLayoutFile()` (hybrid `fs.watch` + 2s polling for cross-window sync). On save, `markOwnWrite()` prevents the watcher from re-reading our own write. External changes push `layoutLoaded` to the webview; skipped if the editor has unsaved changes (last-save-wins). On webview ready: `restoreAgents()` matches persisted entries to live terminals. `nextAgentId`/`nextTerminalIndex` advanced past restored values. **Default layout**: When no saved layout file exists and no workspace state to migrate, a bundled `default-layout.json` is loaded from `assets/` and written to the file. If that also doesn't exist, `createDefaultLayout()` generates a basic office. To update the default: run "Pixel Agents: Export Layout as Default" from the command palette (writes current layout to `webview-ui/public/assets/default-layout.json`), then rebuild. **Export/Import**: Settings modal offers Export Layout (save dialog → JSON file) and Import Layout (open dialog → validates `version: 1` + `tiles` array → writes to layout file + pushes `layoutLoaded` to webview).
93+
**Persistence**: Agents persisted to `workspaceState` key `'pixel-agents.agents'` (includes palette/hueShift/seatId). **Layout persisted to `~/.pixel-agents/layout.json`** (user-level, shared across all VS Code windows/workspaces). `layoutPersistence.ts` handles all file I/O: `readLayoutFromFile()`, `writeLayoutToFile()` (atomic via `.tmp` + rename), `migrateAndLoadLayout()` (checks file → migrates old workspace state → falls back to bundled default), `watchLayoutFile()` (hybrid `fs.watch` + 2s polling for cross-window sync). On save, `markOwnWrite()` prevents the watcher from re-reading our own write. External changes push `layoutLoaded` to the webview; skipped if the editor has unsaved changes (last-save-wins). On webview ready: `restoreAgents()` matches persisted entries to live terminals. `nextAgentId`/`nextTerminalIndex` advanced past restored values. **Default layout**: When no saved layout file exists and no workspace state to migrate, a bundled `default-layout.json` is loaded from `assets/` and written to the file. If that also doesn't exist, `createDefaultLayout()` generates a basic office. To update the default: run "Pixel Agents: Export Layout as Default" from the command palette (writes current layout to `webview-ui/public/assets/default-layout.json`), then rebuild. **Export/Import**: Settings modal offers Export Layout (save dialog → JSON file) and Import Layout (open dialog → validates `version: 1` + `tiles` array → writes to layout file + pushes `layoutLoaded` to webview). **Config persisted to `~/.pixel-agents/config.json`** (user-level, shared across windows). `configPersistence.ts` handles read/write with atomic tmp+rename. Currently stores `externalAssetDirectories: string[]` for external asset pack paths. **External asset directories**: Settings modal offers Add/Remove Asset Directory. External furniture merged with bundled assets on boot and on add/remove via `mergeLoadedAssets()` (external IDs override bundled on collision).
9394

9495
## Office UI
9596

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ This is the source code for the free Pixel Agents extension for VS Code — inst
4141
- **Sound notifications** — optional chime when an agent finishes its turn
4242
- **Sub-agent visualization** — Task tool sub-agents spawn as separate characters linked to their parent
4343
- **Persistent layouts** — your office design is saved and shared across VS Code windows
44+
- **External asset directories** — load custom or third-party furniture packs from any folder on your machine
4445
- **Diverse characters** — 6 diverse characters. These are based on the amazing work of [JIK-A-4, Metro City](https://jik-a-4.itch.io/metrocity-free-topdown-character-pack).
4546

4647
<p align="center">
@@ -96,7 +97,7 @@ Each furniture item lives in its own folder under `assets/furniture/` with a `ma
9697

9798
To add a new furniture item, create a folder in `webview-ui/public/assets/furniture/` with your PNG sprite(s) and a `manifest.json`, then rebuild. The asset manager (`scripts/asset-manager.html`) provides a visual editor for creating and editing manifests.
9899

99-
Detailed documentation on the manifest format and asset pipeline is coming soon.
100+
To use furniture from an external directory, open Settings → **Add Asset Directory**. See [docs/external-assets.md](docs/external-assets.md) for the full manifest format and how to use third-party asset packs.
100101

101102
Characters are based on the amazing work of [JIK-A-4, Metro City](https://jik-a-4.itch.io/metrocity-free-topdown-character-pack).
102103

‎docs/external-assets.md‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# External Asset Directories
2+
3+
Pixel Agents supports loading furniture assets from directories outside the extension. This lets you use custom or third-party pixel art asset packs alongside the built-in furniture.
4+
5+
## Adding an External Directory
6+
7+
1. Open the Pixel Agents panel and click **Settings**
8+
2. Click **Add Asset Directory** and pick a folder
9+
3. Your custom assets will appear in the furniture palette immediately, merged with the built-ins
10+
4. The directory path is saved to `~/.pixel-agents/config.json` and reloaded automatically on restart
11+
12+
To remove a directory, open Settings and click the **X** next to it.
13+
14+
## Directory Structure
15+
16+
Your asset directory must follow this structure:
17+
18+
```
19+
my-assets/
20+
assets/
21+
furniture/
22+
MY_CHAIR/
23+
manifest.json
24+
MY_CHAIR.png
25+
MY_DESK/
26+
manifest.json
27+
MY_DESK_FRONT.png
28+
MY_DESK_SIDE.png
29+
```
30+
31+
Each furniture item gets its own subfolder containing a `manifest.json` and one or more PNG sprite files. The folder name doesn't matter — the `id` field in the manifest is what identifies the item.
32+
33+
## Manifest Format
34+
35+
### Simple asset (single sprite, no rotation)
36+
37+
```json
38+
{
39+
"id": "MY_ITEM",
40+
"name": "My Item",
41+
"category": "decor",
42+
"type": "asset",
43+
"file": "MY_ITEM.png",
44+
"width": 16,
45+
"height": 16,
46+
"footprintW": 1,
47+
"footprintH": 1,
48+
"canPlaceOnWalls": false,
49+
"canPlaceOnSurfaces": false,
50+
"backgroundTiles": 0
51+
}
52+
```
53+
54+
### Rotation group (2-way)
55+
56+
```json
57+
{
58+
"id": "MY_DESK",
59+
"name": "My Desk",
60+
"category": "desks",
61+
"type": "group",
62+
"groupType": "rotation",
63+
"rotationScheme": "2-way",
64+
"canPlaceOnWalls": false,
65+
"canPlaceOnSurfaces": false,
66+
"backgroundTiles": 1,
67+
"members": [
68+
{
69+
"type": "asset",
70+
"id": "MY_DESK_FRONT",
71+
"file": "MY_DESK_FRONT.png",
72+
"width": 32,
73+
"height": 32,
74+
"footprintW": 2,
75+
"footprintH": 2,
76+
"orientation": "front"
77+
},
78+
{
79+
"type": "asset",
80+
"id": "MY_DESK_SIDE",
81+
"file": "MY_DESK_SIDE.png",
82+
"width": 16,
83+
"height": 32,
84+
"footprintW": 1,
85+
"footprintH": 2,
86+
"orientation": "side"
87+
}
88+
]
89+
}
90+
```
91+
92+
### Rotation group (3-way with mirrored side)
93+
94+
Use `"rotationScheme": "3-way-mirror"` and add `"mirrorSide": true` to the side member — the engine auto-generates the mirrored left variant so you only need one side sprite.
95+
96+
```json
97+
{
98+
"id": "MY_CHAIR",
99+
"name": "My Chair",
100+
"category": "chairs",
101+
"type": "group",
102+
"groupType": "rotation",
103+
"rotationScheme": "3-way-mirror",
104+
"canPlaceOnWalls": false,
105+
"canPlaceOnSurfaces": false,
106+
"backgroundTiles": 0,
107+
"members": [
108+
{
109+
"type": "asset",
110+
"id": "MY_CHAIR_FRONT",
111+
"file": "MY_CHAIR_FRONT.png",
112+
"width": 16,
113+
"height": 16,
114+
"footprintW": 1,
115+
"footprintH": 1,
116+
"orientation": "front"
117+
},
118+
{
119+
"type": "asset",
120+
"id": "MY_CHAIR_BACK",
121+
"file": "MY_CHAIR_BACK.png",
122+
"width": 16,
123+
"height": 16,
124+
"footprintW": 1,
125+
"footprintH": 1,
126+
"orientation": "back"
127+
},
128+
{
129+
"type": "asset",
130+
"id": "MY_CHAIR_SIDE",
131+
"file": "MY_CHAIR_SIDE.png",
132+
"width": 16,
133+
"height": 16,
134+
"footprintW": 1,
135+
"footprintH": 1,
136+
"orientation": "side",
137+
"mirrorSide": true
138+
}
139+
]
140+
}
141+
```
142+
143+
## Field Reference
144+
145+
### Root fields (all manifests)
146+
147+
| Field | Type | Description |
148+
|---|---|---|
149+
| `id` | string | Unique identifier. Must be unique across all loaded assets |
150+
| `name` | string | Display name shown in the palette |
151+
| `category` | string | Palette category: `desks`, `chairs`, `electronics`, `storage`, `decor`, `misc`, `wall` |
152+
| `type` | `"asset"` \| `"group"` | Single sprite or grouped (rotation/state/animation) |
153+
| `canPlaceOnWalls` | boolean | Whether the item can be placed on wall tiles |
154+
| `canPlaceOnSurfaces` | boolean | Whether the item can be placed on top of desk surfaces |
155+
| `backgroundTiles` | number | Number of floor tiles the sprite extends below its footprint (for tall sprites) |
156+
157+
### Asset-only fields
158+
159+
| Field | Type | Description |
160+
|---|---|---|
161+
| `file` | string | PNG filename relative to the item folder |
162+
| `width` | number | Sprite width in pixels |
163+
| `height` | number | Sprite height in pixels |
164+
| `footprintW` | number | Footprint width in tiles (1 tile = 16px) |
165+
| `footprintH` | number | Footprint height in tiles |
166+
167+
### Group fields
168+
169+
| Field | Type | Description |
170+
|---|---|---|
171+
| `groupType` | `"rotation"` \| `"state"` \| `"animation"` | How members relate to each other |
172+
| `rotationScheme` | `"2-way"` \| `"3-way-mirror"` \| `"4-way"` | Rotation variants available |
173+
| `members` | array | Child assets or nested groups |
174+
175+
### Member orientation values
176+
177+
`"front"`, `"back"`, `"side"`, `"left"`, `"right"`
178+
179+
## Using Third-Party Asset Packs
180+
181+
If you have a pixel art asset pack (such as **[Office Interior Tileset (16x16)](https://donarg.itch.io/officetileset)** by [Donarg](https://donarg.itch.io/) — highly recommended), you'll need to slice the tileset into individual PNGs and create a `manifest.json` for each item.
182+
183+
The manifest format is simple enough that an AI assistant like Claude Code can generate them for you — just describe your sprites or share the PNGs and ask it to write the manifests.
184+
185+
The `scripts/asset-manager.html` in this repo also provides a visual editor for creating and editing manifests.

‎src/PixelAgentsViewProvider.ts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ import {
1212
sendExistingAgents,
1313
sendLayout,
1414
} from './agentManager.js';
15+
import type { LoadedAssets } from './assetLoader.js';
1516
import {
1617
loadCharacterSprites,
1718
loadDefaultLayout,
1819
loadFloorTiles,
1920
loadFurnitureAssets,
2021
loadWallTiles,
22+
mergeLoadedAssets,
2123
sendAssetsToWebview,
2224
sendCharacterSpritesToWebview,
2325
sendFloorTilesToWebview,
2426
sendWallTilesToWebview,
2527
} from './assetLoader.js';
28+
import { readConfig, writeConfig } from './configPersistence.js';
2629
import {
2730
GLOBAL_KEY_SOUND_ENABLED,
2831
LAYOUT_REVISION_KEY,
@@ -54,6 +57,9 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
5457
// Bundled default layout (loaded from assets/default-layout.json)
5558
defaultLayout: Record<string, unknown> | null = null;
5659

60+
// Root path of bundled assets (set once on first load)
61+
private assetsRoot: string | null = null;
62+
5763
// Cross-window layout sync
5864
layoutWatcher: LayoutWatcher | null = null;
5965

@@ -133,7 +139,12 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
133139
);
134140
// Send persisted settings to webview
135141
const soundEnabled = this.context.globalState.get<boolean>(GLOBAL_KEY_SOUND_ENABLED, true);
136-
this.webview?.postMessage({ type: 'settingsLoaded', soundEnabled });
142+
const config = readConfig();
143+
this.webview?.postMessage({
144+
type: 'settingsLoaded',
145+
soundEnabled,
146+
externalAssetDirectories: config.externalAssetDirectories,
147+
});
137148

138149
// Send workspace folders to webview (only when multi-root)
139150
const wsFolders = vscode.workspace.workspaceFolders;
@@ -194,6 +205,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
194205
}
195206

196207
console.log('[Extension] Using assetsRoot:', assetsRoot);
208+
this.assetsRoot = assetsRoot;
197209

198210
// Load bundled default layout
199211
this.defaultLayout = loadDefaultLayout(assetsRoot);
@@ -219,7 +231,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
219231
sendWallTilesToWebview(this.webview, wallTiles);
220232
}
221233

222-
const assets = await loadFurnitureAssets(assetsRoot);
234+
const assets = await this.loadAllFurnitureAssets();
223235
if (assets && this.webview) {
224236
console.log('[Extension] ✅ Assets loaded, sending to webview');
225237
sendAssetsToWebview(this.webview, assets);
@@ -285,6 +297,36 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
285297
fs.writeFileSync(uri.fsPath, JSON.stringify(layout, null, 2), 'utf-8');
286298
vscode.window.showInformationMessage('Pixel Agents: Layout exported successfully.');
287299
}
300+
} else if (message.type === 'addExternalAssetDirectory') {
301+
const uris = await vscode.window.showOpenDialog({
302+
canSelectFolders: true,
303+
canSelectFiles: false,
304+
canSelectMany: false,
305+
openLabel: 'Select Asset Directory',
306+
});
307+
if (!uris || uris.length === 0) return;
308+
const newPath = uris[0].fsPath;
309+
const cfg = readConfig();
310+
if (!cfg.externalAssetDirectories.includes(newPath)) {
311+
cfg.externalAssetDirectories.push(newPath);
312+
writeConfig(cfg);
313+
}
314+
await this.reloadAndSendFurniture();
315+
this.webview?.postMessage({
316+
type: 'externalAssetDirectoriesUpdated',
317+
dirs: cfg.externalAssetDirectories,
318+
});
319+
} else if (message.type === 'removeExternalAssetDirectory') {
320+
const cfg = readConfig();
321+
cfg.externalAssetDirectories = cfg.externalAssetDirectories.filter(
322+
(d) => d !== (message.path as string),
323+
);
324+
writeConfig(cfg);
325+
await this.reloadAndSendFurniture();
326+
this.webview?.postMessage({
327+
type: 'externalAssetDirectoriesUpdated',
328+
dirs: cfg.externalAssetDirectories,
329+
});
288330
} else if (message.type === 'importLayout') {
289331
const uris = await vscode.window.showOpenDialog({
290332
filters: { 'JSON Files': ['json'] },
@@ -377,6 +419,32 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
377419
);
378420
}
379421

422+
private async loadAllFurnitureAssets(): Promise<LoadedAssets | null> {
423+
if (!this.assetsRoot) return null;
424+
let assets = await loadFurnitureAssets(this.assetsRoot);
425+
const config = readConfig();
426+
for (const extraDir of config.externalAssetDirectories) {
427+
console.log('[Extension] Loading external assets from:', extraDir);
428+
const extra = await loadFurnitureAssets(extraDir);
429+
if (extra) {
430+
assets = assets ? mergeLoadedAssets(assets, extra) : extra;
431+
}
432+
}
433+
return assets;
434+
}
435+
436+
private async reloadAndSendFurniture(): Promise<void> {
437+
if (!this.assetsRoot || !this.webview) return;
438+
try {
439+
const assets = await this.loadAllFurnitureAssets();
440+
if (assets) {
441+
sendAssetsToWebview(this.webview, assets);
442+
}
443+
} catch (err) {
444+
console.error('[Extension] Error reloading furniture assets:', err);
445+
}
446+
}
447+
380448
private startLayoutWatcher(): void {
381449
if (this.layoutWatcher) return;
382450
this.layoutWatcher = watchLayoutFile((layout) => {

0 commit comments

Comments
 (0)