Skip to content

Commit b1124e8

Browse files
NNTinflorintimbuc
andauthored
feat: preview pixel agents in web browser (additional support tool for dev and review) (#143)
* feat: debug mode in browser * refactor: move asset pipeline modules to shared/assets/ domain directory * refactor: single source of truth for runtime detection * fix: no longer bundling character, floor, wall and furniture JSONs --------- Co-authored-by: Florin Timbuc <florin@sowild.design>
1 parent 6680e32 commit b1124e8

21 files changed

Lines changed: 1568 additions & 850 deletions

.vscode/tasks.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@
5656
"reveal": "never"
5757
}
5858
},
59+
{
60+
"label": "Mocked Pixel Agent Dev Server",
61+
"type": "shell",
62+
"command": "cd webview-ui && npm run dev",
63+
"isBackground": true,
64+
"group": "build",
65+
"presentation": {
66+
"reveal": "always",
67+
"panel": "dedicated"
68+
},
69+
"problemMatcher": []
70+
},
5971
{
6072
"label": "Run CI (act)",
6173
"type": "shell",

CONTRIBUTING.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ This starts parallel watchers for both the extension backend (esbuild) and TypeS
3535

3636
> **Note:** The webview (Vite) is not included in `watch` — after changing webview code, run `npm run build:webview` or the full `npm run build`.
3737
38+
## Running the Mocked Pixel Agent
39+
40+
You can run the mocked Pixel Agent web app either from the CLI or from VS Code tasks.
41+
42+
### Option 1: CLI
43+
44+
From the repository root:
45+
46+
```bash
47+
cd webview-ui
48+
npm run dev
49+
```
50+
51+
Vite will print a local URL (typically `http://localhost:5173`) where the mocked app is available.
52+
53+
### Option 2: VS Code Run Task
54+
55+
1. Open the command palette and run **Tasks: Run Task**.
56+
2. Select **Mocked Pixel Agent Dev Server**.
57+
3. Open the local URL shown in the task terminal output (typically `http://localhost:5173`).
58+
3859
### Project Structure
3960

4061
| Directory | Description |

shared/assets/build.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Build-time asset generators — shared between Vite plugin, extension host,
3+
* and future standalone backends.
4+
*
5+
* Reads furniture manifests and asset directories and produces
6+
* catalog and index structures.
7+
*/
8+
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
import type { CatalogEntry } from './types.js';
13+
import type { FurnitureManifest, InheritedProps, ManifestGroup } from './manifestUtils.js';
14+
import { flattenManifest } from './manifestUtils.js';
15+
16+
// ── Furniture catalog ─────────────────────────────────────────────────────────
17+
18+
export function buildFurnitureCatalog(assetsDir: string): CatalogEntry[] {
19+
const furnitureDir = path.join(assetsDir, 'furniture');
20+
if (!fs.existsSync(furnitureDir)) return [];
21+
22+
const catalog: CatalogEntry[] = [];
23+
const dirs = fs
24+
.readdirSync(furnitureDir, { withFileTypes: true })
25+
.filter((e) => e.isDirectory())
26+
.map((e) => e.name)
27+
.sort();
28+
29+
for (const folderName of dirs) {
30+
const manifestPath = path.join(furnitureDir, folderName, 'manifest.json');
31+
if (!fs.existsSync(manifestPath)) continue;
32+
try {
33+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as FurnitureManifest;
34+
35+
if (manifest.type === 'asset') {
36+
// Single-asset manifest — validate required fields
37+
if (
38+
manifest.width == null ||
39+
manifest.height == null ||
40+
manifest.footprintW == null ||
41+
manifest.footprintH == null
42+
) {
43+
continue;
44+
}
45+
const file = manifest.file ?? `${manifest.id}.png`;
46+
catalog.push({
47+
id: manifest.id,
48+
name: manifest.name,
49+
label: manifest.name,
50+
category: manifest.category,
51+
file,
52+
furniturePath: `furniture/${folderName}/${file}`,
53+
width: manifest.width,
54+
height: manifest.height,
55+
footprintW: manifest.footprintW,
56+
footprintH: manifest.footprintH,
57+
isDesk: manifest.category === 'desks',
58+
canPlaceOnWalls: manifest.canPlaceOnWalls,
59+
canPlaceOnSurfaces: manifest.canPlaceOnSurfaces,
60+
backgroundTiles: manifest.backgroundTiles,
61+
groupId: manifest.id,
62+
});
63+
} else {
64+
// Group manifest — flatten into individual assets
65+
if (!manifest.members) continue;
66+
const inherited: InheritedProps = {
67+
groupId: manifest.id,
68+
name: manifest.name,
69+
category: manifest.category,
70+
canPlaceOnWalls: manifest.canPlaceOnWalls,
71+
canPlaceOnSurfaces: manifest.canPlaceOnSurfaces,
72+
backgroundTiles: manifest.backgroundTiles,
73+
...(manifest.rotationScheme ? { rotationScheme: manifest.rotationScheme } : {}),
74+
};
75+
const rootGroup: ManifestGroup = {
76+
type: 'group',
77+
groupType: manifest.groupType as 'rotation' | 'state' | 'animation',
78+
rotationScheme: manifest.rotationScheme,
79+
members: manifest.members,
80+
};
81+
const assets = flattenManifest(rootGroup, inherited);
82+
for (const asset of assets) {
83+
catalog.push({
84+
...asset,
85+
furniturePath: `furniture/${folderName}/${asset.file}`,
86+
});
87+
}
88+
}
89+
} catch {
90+
// skip malformed manifests
91+
}
92+
}
93+
return catalog;
94+
}
95+
96+
// ── Asset index ───────────────────────────────────────────────────────────────
97+
98+
export function buildAssetIndex(assetsDir: string) {
99+
function listSorted(subdir: string, pattern: RegExp): string[] {
100+
const dir = path.join(assetsDir, subdir);
101+
if (!fs.existsSync(dir)) return [];
102+
return fs
103+
.readdirSync(dir)
104+
.filter((f) => pattern.test(f))
105+
.sort((a, b) => {
106+
const na = parseInt(/(\d+)/.exec(a)?.[1] ?? '0', 10);
107+
const nb = parseInt(/(\d+)/.exec(b)?.[1] ?? '0', 10);
108+
return na - nb;
109+
});
110+
}
111+
112+
let defaultLayout: string | null = null;
113+
let bestRev = 0;
114+
if (fs.existsSync(assetsDir)) {
115+
for (const f of fs.readdirSync(assetsDir)) {
116+
const m = /^default-layout-(\d+)\.json$/.exec(f);
117+
if (m) {
118+
const rev = parseInt(m[1], 10);
119+
if (rev > bestRev) {
120+
bestRev = rev;
121+
defaultLayout = f;
122+
}
123+
}
124+
}
125+
if (!defaultLayout && fs.existsSync(path.join(assetsDir, 'default-layout.json'))) {
126+
defaultLayout = 'default-layout.json';
127+
}
128+
}
129+
130+
return {
131+
floors: listSorted('floors', /^floor_\d+\.png$/i),
132+
walls: listSorted('walls', /^wall_\d+\.png$/i),
133+
characters: listSorted('characters', /^char_\d+\.png$/i),
134+
defaultLayout,
135+
};
136+
}

shared/assets/constants.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Shared constants — used by the extension host, Vite build scripts,
3+
* and future standalone backend.
4+
*
5+
* No VS Code dependency. Only asset parsing and layout-related values.
6+
*/
7+
8+
// ── PNG / Asset Parsing ─────────────────────────────────────
9+
export const PNG_ALPHA_THRESHOLD = 2;
10+
export const WALL_PIECE_WIDTH = 16;
11+
export const WALL_PIECE_HEIGHT = 32;
12+
export const WALL_GRID_COLS = 4;
13+
export const WALL_BITMASK_COUNT = 16;
14+
export const FLOOR_TILE_SIZE = 16;
15+
export const CHARACTER_DIRECTIONS = ['down', 'up', 'right'] as const;
16+
export const CHAR_FRAME_W = 16;
17+
export const CHAR_FRAME_H = 32;
18+
export const CHAR_FRAMES_PER_ROW = 7;
19+
export const CHAR_COUNT = 6;

shared/assets/loader.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Server-side asset decoders — shared between Vite plugin, extension host,
3+
* and future standalone backends.
4+
*
5+
* Reads PNG files from an assets directory and decodes them into SpriteData
6+
* format using the shared pngDecoder module.
7+
*/
8+
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
import { decodeCharacterPng, decodeFloorPng, parseWallPng, pngToSpriteData } from './pngDecoder.js';
13+
import type { CatalogEntry, CharacterDirectionSprites } from './types.js';
14+
15+
// ── Helpers ──────────────────────────────────────────────────────────────────
16+
17+
function listSortedPngs(dir: string, pattern: RegExp): { index: number; filename: string }[] {
18+
if (!fs.existsSync(dir)) return [];
19+
const files: { index: number; filename: string }[] = [];
20+
for (const entry of fs.readdirSync(dir)) {
21+
const match = pattern.exec(entry);
22+
if (match) {
23+
files.push({ index: parseInt(match[1], 10), filename: entry });
24+
}
25+
}
26+
return files.sort((a, b) => a.index - b.index);
27+
}
28+
29+
// ── Decoders ─────────────────────────────────────────────────────────────────
30+
31+
export function decodeAllCharacters(assetsDir: string): CharacterDirectionSprites[] {
32+
const charDir = path.join(assetsDir, 'characters');
33+
const files = listSortedPngs(charDir, /^char_(\d+)\.png$/i);
34+
return files.map(({ filename }) => {
35+
const pngBuffer = fs.readFileSync(path.join(charDir, filename));
36+
return decodeCharacterPng(pngBuffer);
37+
});
38+
}
39+
40+
export function decodeAllFloors(assetsDir: string): string[][][] {
41+
const floorsDir = path.join(assetsDir, 'floors');
42+
const files = listSortedPngs(floorsDir, /^floor_(\d+)\.png$/i);
43+
return files.map(({ filename }) => {
44+
const pngBuffer = fs.readFileSync(path.join(floorsDir, filename));
45+
return decodeFloorPng(pngBuffer);
46+
});
47+
}
48+
49+
export function decodeAllWalls(assetsDir: string): string[][][][] {
50+
const wallsDir = path.join(assetsDir, 'walls');
51+
const files = listSortedPngs(wallsDir, /^wall_(\d+)\.png$/i);
52+
return files.map(({ filename }) => {
53+
const pngBuffer = fs.readFileSync(path.join(wallsDir, filename));
54+
return parseWallPng(pngBuffer);
55+
});
56+
}
57+
58+
export function decodeAllFurniture(
59+
assetsDir: string,
60+
catalog: CatalogEntry[],
61+
): Record<string, string[][]> {
62+
const sprites: Record<string, string[][]> = {};
63+
for (const entry of catalog) {
64+
try {
65+
const filePath = path.join(assetsDir, entry.furniturePath);
66+
if (!fs.existsSync(filePath)) continue;
67+
const pngBuffer = fs.readFileSync(filePath);
68+
sprites[entry.id] = pngToSpriteData(pngBuffer, entry.width, entry.height);
69+
} catch (err) {
70+
console.warn(`[decodeAssets] Failed to decode ${entry.id}:`, err);
71+
}
72+
}
73+
return sprites;
74+
}

0 commit comments

Comments
 (0)