Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@
"reveal": "never"
}
},
{
"label": "Mocked Pixel Agent Dev Server",
"type": "shell",
"command": "cd webview-ui && npm run dev",
"isBackground": true,
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "Run CI (act)",
"type": "shell",
Expand Down
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ This starts parallel watchers for both the extension backend (esbuild) and TypeS

> **Note:** The webview (Vite) is not included in `watch` — after changing webview code, run `npm run build:webview` or the full `npm run build`.

## Running the Mocked Pixel Agent

You can run the mocked Pixel Agent web app either from the CLI or from VS Code tasks.

### Option 1: CLI

From the repository root:

```bash
cd webview-ui
npm run dev
```

Vite will print a local URL (typically `http://localhost:5173`) where the mocked app is available.

### Option 2: VS Code Run Task

1. Open the command palette and run **Tasks: Run Task**.
2. Select **Mocked Pixel Agent Dev Server**.
3. Open the local URL shown in the task terminal output (typically `http://localhost:5173`).

### Project Structure

| Directory | Description |
Expand Down
136 changes: 136 additions & 0 deletions shared/assets/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Build-time asset generators — shared between Vite plugin, extension host,
* and future standalone backends.
*
* Reads furniture manifests and asset directories and produces
* catalog and index structures.
*/

import * as fs from 'fs';
import * as path from 'path';

import type { CatalogEntry } from './types.js';
import type { FurnitureManifest, InheritedProps, ManifestGroup } from './manifestUtils.js';
import { flattenManifest } from './manifestUtils.js';

// ── Furniture catalog ─────────────────────────────────────────────────────────

export function buildFurnitureCatalog(assetsDir: string): CatalogEntry[] {
const furnitureDir = path.join(assetsDir, 'furniture');
if (!fs.existsSync(furnitureDir)) return [];

const catalog: CatalogEntry[] = [];
const dirs = fs
.readdirSync(furnitureDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();

for (const folderName of dirs) {
const manifestPath = path.join(furnitureDir, folderName, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as FurnitureManifest;

if (manifest.type === 'asset') {
// Single-asset manifest — validate required fields
if (
manifest.width == null ||
manifest.height == null ||
manifest.footprintW == null ||
manifest.footprintH == null
) {
continue;
}
const file = manifest.file ?? `${manifest.id}.png`;
catalog.push({
id: manifest.id,
name: manifest.name,
label: manifest.name,
category: manifest.category,
file,
furniturePath: `furniture/${folderName}/${file}`,
width: manifest.width,
height: manifest.height,
footprintW: manifest.footprintW,
footprintH: manifest.footprintH,
isDesk: manifest.category === 'desks',
canPlaceOnWalls: manifest.canPlaceOnWalls,
canPlaceOnSurfaces: manifest.canPlaceOnSurfaces,
backgroundTiles: manifest.backgroundTiles,
groupId: manifest.id,
});
} else {
// Group manifest — flatten into individual assets
if (!manifest.members) continue;
const inherited: InheritedProps = {
groupId: manifest.id,
name: manifest.name,
category: manifest.category,
canPlaceOnWalls: manifest.canPlaceOnWalls,
canPlaceOnSurfaces: manifest.canPlaceOnSurfaces,
backgroundTiles: manifest.backgroundTiles,
...(manifest.rotationScheme ? { rotationScheme: manifest.rotationScheme } : {}),
};
const rootGroup: ManifestGroup = {
type: 'group',
groupType: manifest.groupType as 'rotation' | 'state' | 'animation',
rotationScheme: manifest.rotationScheme,
members: manifest.members,
};
const assets = flattenManifest(rootGroup, inherited);
for (const asset of assets) {
catalog.push({
...asset,
furniturePath: `furniture/${folderName}/${asset.file}`,
});
}
}
} catch {
// skip malformed manifests
}
}
return catalog;
}

// ── Asset index ───────────────────────────────────────────────────────────────

export function buildAssetIndex(assetsDir: string) {
function listSorted(subdir: string, pattern: RegExp): string[] {
const dir = path.join(assetsDir, subdir);
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => pattern.test(f))
.sort((a, b) => {
const na = parseInt(/(\d+)/.exec(a)?.[1] ?? '0', 10);
const nb = parseInt(/(\d+)/.exec(b)?.[1] ?? '0', 10);
return na - nb;
});
}

let defaultLayout: string | null = null;
let bestRev = 0;
if (fs.existsSync(assetsDir)) {
for (const f of fs.readdirSync(assetsDir)) {
const m = /^default-layout-(\d+)\.json$/.exec(f);
if (m) {
const rev = parseInt(m[1], 10);
if (rev > bestRev) {
bestRev = rev;
defaultLayout = f;
}
}
}
if (!defaultLayout && fs.existsSync(path.join(assetsDir, 'default-layout.json'))) {
defaultLayout = 'default-layout.json';
}
}

return {
floors: listSorted('floors', /^floor_\d+\.png$/i),
walls: listSorted('walls', /^wall_\d+\.png$/i),
characters: listSorted('characters', /^char_\d+\.png$/i),
defaultLayout,
};
}
19 changes: 19 additions & 0 deletions shared/assets/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Shared constants — used by the extension host, Vite build scripts,
* and future standalone backend.
*
* No VS Code dependency. Only asset parsing and layout-related values.
*/

// ── PNG / Asset Parsing ─────────────────────────────────────
export const PNG_ALPHA_THRESHOLD = 2;
export const WALL_PIECE_WIDTH = 16;
export const WALL_PIECE_HEIGHT = 32;
export const WALL_GRID_COLS = 4;
export const WALL_BITMASK_COUNT = 16;
export const FLOOR_TILE_SIZE = 16;
export const CHARACTER_DIRECTIONS = ['down', 'up', 'right'] as const;
export const CHAR_FRAME_W = 16;
export const CHAR_FRAME_H = 32;
export const CHAR_FRAMES_PER_ROW = 7;
export const CHAR_COUNT = 6;
74 changes: 74 additions & 0 deletions shared/assets/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Server-side asset decoders — shared between Vite plugin, extension host,
* and future standalone backends.
*
* Reads PNG files from an assets directory and decodes them into SpriteData
* format using the shared pngDecoder module.
*/

import * as fs from 'fs';
import * as path from 'path';

import { decodeCharacterPng, decodeFloorPng, parseWallPng, pngToSpriteData } from './pngDecoder.js';
import type { CatalogEntry, CharacterDirectionSprites } from './types.js';

// ── Helpers ──────────────────────────────────────────────────────────────────

function listSortedPngs(dir: string, pattern: RegExp): { index: number; filename: string }[] {
if (!fs.existsSync(dir)) return [];
const files: { index: number; filename: string }[] = [];
for (const entry of fs.readdirSync(dir)) {
const match = pattern.exec(entry);
if (match) {
files.push({ index: parseInt(match[1], 10), filename: entry });
}
}
return files.sort((a, b) => a.index - b.index);
}

// ── Decoders ─────────────────────────────────────────────────────────────────

export function decodeAllCharacters(assetsDir: string): CharacterDirectionSprites[] {
const charDir = path.join(assetsDir, 'characters');
const files = listSortedPngs(charDir, /^char_(\d+)\.png$/i);
return files.map(({ filename }) => {
const pngBuffer = fs.readFileSync(path.join(charDir, filename));
return decodeCharacterPng(pngBuffer);
});
}

export function decodeAllFloors(assetsDir: string): string[][][] {
const floorsDir = path.join(assetsDir, 'floors');
const files = listSortedPngs(floorsDir, /^floor_(\d+)\.png$/i);
return files.map(({ filename }) => {
const pngBuffer = fs.readFileSync(path.join(floorsDir, filename));
return decodeFloorPng(pngBuffer);
});
}

export function decodeAllWalls(assetsDir: string): string[][][][] {
const wallsDir = path.join(assetsDir, 'walls');
const files = listSortedPngs(wallsDir, /^wall_(\d+)\.png$/i);
return files.map(({ filename }) => {
const pngBuffer = fs.readFileSync(path.join(wallsDir, filename));
return parseWallPng(pngBuffer);
});
}

export function decodeAllFurniture(
assetsDir: string,
catalog: CatalogEntry[],
): Record<string, string[][]> {
const sprites: Record<string, string[][]> = {};
for (const entry of catalog) {
try {
const filePath = path.join(assetsDir, entry.furniturePath);
if (!fs.existsSync(filePath)) continue;
const pngBuffer = fs.readFileSync(filePath);
sprites[entry.id] = pngToSpriteData(pngBuffer, entry.width, entry.height);
} catch (err) {
console.warn(`[decodeAssets] Failed to decode ${entry.id}:`, err);
}
}
return sprites;
}
Loading
Loading