Agents_Team/web/src/components/PixelAgentsOffice.tsx
2026-03-16 15:22:39 +08:00

573 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { OfficeState as WorkbenchOfficeState } from "../types";
import { OfficeCanvas as VendorOfficeCanvas } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/components/OfficeCanvas";
import { EditorState } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/editor/editorState";
import { EditorToolbar } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/editor/EditorToolbar";
import { OfficeState as VendorOfficeState } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/engine/officeState";
import { buildDynamicCatalog } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/layout/furnitureCatalog";
import { migrateLayoutColors } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/layout/layoutSerializer";
import { setFloorSprites } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/floorTiles";
import { setWallSprites } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/wallTiles";
import { setCharacterTemplates } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/sprites/spriteData";
import type { FloorColor, OfficeLayout, SpriteData } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/types";
import { useEditorActions } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/hooks/useEditorActions";
type FurnitureCatalogAsset = {
id: string;
label: string;
category: string;
file: string;
width: number;
height: number;
footprintW: number;
footprintH: number;
isDesk: boolean;
groupId?: string;
orientation?: string;
state?: string;
canPlaceOnSurfaces?: boolean;
backgroundTiles?: number;
canPlaceOnWalls?: boolean;
};
type FurnitureCatalogResponse = {
assets: FurnitureCatalogAsset[];
};
type CharacterDirectionSprites = {
down: SpriteData[];
up: SpriteData[];
right: SpriteData[];
};
const TILE_SIZE = 16;
const WALL_PIECE_WIDTH = 16;
const WALL_PIECE_HEIGHT = 32;
const WALL_GRID_COLS = 4;
const WALL_BITMASK_COUNT = 16;
const FLOOR_PATTERN_COUNT = 7;
const CHAR_FRAME_W = 16;
const CHAR_FRAME_H = 32;
const CHAR_FRAMES_PER_ROW = 7;
const CHAR_DIRECTIONS = ["down", "up", "right"] as const;
const PNG_ALPHA_THRESHOLD = 128;
const createEmptyLayout = (): OfficeLayout =>
migrateLayoutColors({
version: 1,
cols: 21,
rows: 21,
tiles: [],
furniture: []
});
const loadImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`无法加载图片资源:${url}`));
image.src = url;
});
const readSpriteData = (
image: HTMLImageElement,
sx: number,
sy: number,
width: number,
height: number
): SpriteData => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return Array.from({ length: height }, () => Array(width).fill(""));
}
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
context.drawImage(image, sx, sy, width, height, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const sprite: string[][] = [];
for (let row = 0; row < height; row += 1) {
const line: string[] = [];
for (let col = 0; col < width; col += 1) {
const index = (row * width + col) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a < PNG_ALPHA_THRESHOLD) {
line.push("");
} else {
line.push(
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase()
);
}
}
sprite.push(line);
}
return sprite;
};
const loadFloorSpritesFromImage = async (url: string): Promise<SpriteData[]> => {
const image = await loadImage(url);
return Array.from({ length: FLOOR_PATTERN_COUNT }, (_, index) =>
readSpriteData(image, index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE)
);
};
const loadWallSpritesFromImage = async (url: string): Promise<SpriteData[]> => {
const image = await loadImage(url);
return Array.from({ length: WALL_BITMASK_COUNT }, (_, mask) =>
readSpriteData(
image,
(mask % WALL_GRID_COLS) * WALL_PIECE_WIDTH,
Math.floor(mask / WALL_GRID_COLS) * WALL_PIECE_HEIGHT,
WALL_PIECE_WIDTH,
WALL_PIECE_HEIGHT
)
);
};
const loadCharacterSprites = async (urls: string[]): Promise<CharacterDirectionSprites[]> =>
Promise.all(
urls.map(async (url) => {
const image = await loadImage(url);
const result: CharacterDirectionSprites = { down: [], up: [], right: [] };
for (let dirIndex = 0; dirIndex < CHAR_DIRECTIONS.length; dirIndex += 1) {
const direction = CHAR_DIRECTIONS[dirIndex];
result[direction] = Array.from({ length: CHAR_FRAMES_PER_ROW }, (_, frameIndex) =>
readSpriteData(image, frameIndex * CHAR_FRAME_W, dirIndex * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H)
);
}
return result;
})
);
const loadFurnitureSprites = async (
catalogUrl: string,
assetBaseUrl: string
): Promise<{ catalog: FurnitureCatalogAsset[]; sprites: Record<string, SpriteData> }> => {
const catalogResponse = await fetch(catalogUrl);
if (!catalogResponse.ok) {
throw new Error("无法读取家具目录。");
}
const catalogData = (await catalogResponse.json()) as FurnitureCatalogResponse;
const catalog = catalogData.assets || [];
const spritesEntries = await Promise.all(
catalog.map(async (asset) => {
const image = await loadImage(`${assetBaseUrl}/${asset.file}`);
return [asset.id, readSpriteData(image, 0, 0, asset.width, asset.height)] as const;
})
);
return {
catalog,
sprites: Object.fromEntries(spritesEntries)
};
};
const pickNearestSeatId = (
officeState: VendorOfficeState,
targetCol: number | null,
targetRow: number | null
): string | null => {
const freeSeats = Array.from(officeState.seats.entries()).filter(([, seat]) => !seat.assigned);
if (freeSeats.length === 0) {
return null;
}
if (targetCol === null || targetRow === null) {
return freeSeats[0][0];
}
freeSeats.sort((left, right) => {
const leftDistance = Math.abs(left[1].seatCol - targetCol) + Math.abs(left[1].seatRow - targetRow);
const rightDistance = Math.abs(right[1].seatCol - targetCol) + Math.abs(right[1].seatRow - targetRow);
return leftDistance - rightDistance;
});
return freeSeats[0][0];
};
const syncOfficeState = (
vendorOfficeState: VendorOfficeState,
office: WorkbenchOfficeState,
actorIdToNumericId: Map<string, number>,
selectedActorId: string | null
) => {
const activeIds = new Set(actorIdToNumericId.values());
for (const characterId of Array.from(vendorOfficeState.characters.keys())) {
if (characterId > 0 && !activeIds.has(characterId)) {
vendorOfficeState.removeAgent(characterId);
}
}
for (const agent of office.agents) {
const numericId = actorIdToNumericId.get(agent.actorId);
if (!numericId) {
continue;
}
if (!vendorOfficeState.characters.has(numericId)) {
const preferredSeatId = pickNearestSeatId(vendorOfficeState, agent.positionCol, agent.positionRow);
vendorOfficeState.addAgent(numericId, agent.characterIndex ?? 0, 0, preferredSeatId || undefined, true);
}
const isActive = agent.presence !== "blocked" && agent.presence !== "done";
vendorOfficeState.setAgentActive(numericId, isActive);
vendorOfficeState.setAgentTool(
numericId,
agent.presence === "reading" ? "Read" : isActive ? agent.activeTaskTitle || "Write" : null
);
if (agent.presence === "waiting") {
vendorOfficeState.showWaitingBubble(numericId);
} else {
vendorOfficeState.dismissBubble(numericId);
}
}
vendorOfficeState.selectedAgentId = selectedActorId ? actorIdToNumericId.get(selectedActorId) ?? null : null;
};
export function PixelAgentsOffice({
office,
selectedActorId,
onSelectActor
}: {
office: WorkbenchOfficeState;
selectedActorId: string | null;
onSelectActor: (actorId: string) => void;
}) {
const actorIdToNumericId = useMemo(
() => new Map(office.agents.map((agent, index) => [agent.actorId, index + 1])),
[office.agents]
);
const numericIdToActorId = useMemo(
() => new Map(office.agents.map((agent, index) => [index + 1, agent.actorId])),
[office.agents]
);
const officeStateRef = useRef<VendorOfficeState | null>(null);
const editorStateRef = useRef(new EditorState());
const panRef = useRef({ x: 0, y: 0 });
const frameRef = useRef<HTMLDivElement | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const defaultLayoutRef = useRef<OfficeLayout | null>(null);
const [ready, setReady] = useState(false);
const [zoom, setZoom] = useState(2);
const [loadError, setLoadError] = useState("");
const [loadedAssets, setLoadedAssets] = useState<{ catalog: FurnitureCatalogAsset[]; sprites: Record<string, SpriteData> }>();
const [showUtilityPanel, setShowUtilityPanel] = useState(false);
const [showEditorPanel, setShowEditorPanel] = useState(false);
const userZoomRef = useRef(false);
const editor = useEditorActions(
() => officeStateRef.current || new VendorOfficeState(createEmptyLayout()),
editorStateRef.current
);
useEffect(() => {
let disposed = false;
const loadAssets = async () => {
try {
setLoadError("");
const [layoutResponse, floorSprites, wallSprites, characterSprites, furnitureAssets] = await Promise.all([
office.assets.defaultLayoutUrl ? fetch(office.assets.defaultLayoutUrl) : Promise.resolve(null),
office.assets.floorsUrl ? loadFloorSpritesFromImage(office.assets.floorsUrl) : Promise.resolve([]),
office.assets.wallsUrl ? loadWallSpritesFromImage(office.assets.wallsUrl) : Promise.resolve([]),
loadCharacterSprites(office.assets.characterUrls),
office.assets.furnitureCatalogUrl && office.assets.assetBaseUrl
? loadFurnitureSprites(office.assets.furnitureCatalogUrl, office.assets.assetBaseUrl)
: Promise.resolve({ catalog: [], sprites: {} })
]);
if (disposed) {
return;
}
const rawLayout = layoutResponse && layoutResponse.ok ? ((await layoutResponse.json()) as OfficeLayout) : createEmptyLayout();
const layout = migrateLayoutColors(rawLayout);
defaultLayoutRef.current = structuredClone(layout);
setFloorSprites(floorSprites);
setWallSprites(wallSprites);
setCharacterTemplates(characterSprites);
buildDynamicCatalog(furnitureAssets);
setLoadedAssets(furnitureAssets);
officeStateRef.current = new VendorOfficeState(layout);
editor.setLastSavedLayout(layout);
syncOfficeState(officeStateRef.current, office, actorIdToNumericId, selectedActorId);
setReady(true);
} catch (error) {
if (!disposed) {
setLoadError(error instanceof Error ? error.message : "像素办公室资源加载失败。");
setReady(false);
}
}
};
void loadAssets();
return () => {
disposed = true;
};
}, [
office.assets.defaultLayoutUrl,
office.assets.floorsUrl,
office.assets.wallsUrl,
office.assets.characterUrls,
office.assets.furnitureCatalogUrl,
office.assets.assetBaseUrl
]);
useEffect(() => {
if (!officeStateRef.current) {
return;
}
syncOfficeState(officeStateRef.current, office, actorIdToNumericId, selectedActorId);
}, [office, actorIdToNumericId, selectedActorId]);
useEffect(() => {
if (!ready || !officeStateRef.current) {
return;
}
const frame = frameRef.current;
if (!frame) {
return;
}
const updateZoom = () => {
const layout = officeStateRef.current?.getLayout();
if (!layout) {
return;
}
const availableWidth = Math.max(240, frame.clientWidth - 8);
const availableHeight = Math.max(240, frame.clientHeight - 8);
const widthZoom = Math.floor(availableWidth / (layout.cols * TILE_SIZE));
const heightZoom = Math.floor(availableHeight / (layout.rows * TILE_SIZE));
const nextZoom = Math.max(1, Math.min(10, Math.min(widthZoom, heightZoom)));
if (!userZoomRef.current) {
setZoom((current) => (current === nextZoom ? current : nextZoom));
}
};
updateZoom();
const observer = new ResizeObserver(updateZoom);
observer.observe(frame);
return () => observer.disconnect();
}, [ready, office]);
const resetToDefaultLayout = useMemo(
() => () => {
const layout = defaultLayoutRef.current;
const officeState = officeStateRef.current;
if (!layout || !officeState) {
return;
}
const restored = structuredClone(layout);
officeState.rebuildFromLayout(restored);
editorStateRef.current.reset();
editor.setLastSavedLayout(restored);
userZoomRef.current = false;
setShowEditorPanel(false);
setShowUtilityPanel(false);
},
[editor]
);
useEffect(() => {
if (!ready) {
return;
}
const resetFlag = "agents-team:pixel-office-reset-v1";
if (!window.sessionStorage.getItem(resetFlag)) {
resetToDefaultLayout();
window.sessionStorage.setItem(resetFlag, "1");
}
}, [ready, resetToDefaultLayout]);
if (!ready || !officeStateRef.current) {
return <div className="pixel-agents-office-loading">{loadError || "正在加载原版像素办公室..."}</div>;
}
const selectedFurnitureColor =
officeStateRef.current
.getLayout()
.furniture.find((item) => item.uid === editorStateRef.current.selectedFurnitureUid)?.color ?? null;
const exportLayout = () => {
const layout = officeStateRef.current?.getLayout();
if (!layout) {
return;
}
const blob = new Blob([JSON.stringify(layout, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "pixel-office-layout.json";
anchor.click();
URL.revokeObjectURL(url);
};
const importLayout = async (file: File) => {
const content = await file.text();
const parsed = JSON.parse(content) as OfficeLayout;
const migrated = migrateLayoutColors(parsed);
officeStateRef.current?.rebuildFromLayout(migrated);
editor.setLastSavedLayout(migrated);
};
return (
<div className="pixel-agents-office-root">
<div className="pixel-agents-office-toolbar">
<button
type="button"
className={`pixel-agents-office-btn ${editor.isEditMode ? "is-active" : ""}`}
onClick={() => {
const nextEditMode = !editor.isEditMode;
editor.handleToggleEditMode();
setShowEditorPanel(nextEditMode);
}}
>
{editor.isEditMode ? "退出布局编辑" : "布局编辑"}
</button>
<button
type="button"
className={`pixel-agents-office-btn ${showUtilityPanel ? "is-active" : ""}`}
onClick={() => setShowUtilityPanel((current) => !current)}
>
工具
</button>
{editor.isEditMode ? (
<button
type="button"
className={`pixel-agents-office-btn ${showEditorPanel ? "is-active" : ""}`}
onClick={() => setShowEditorPanel((current) => !current)}
>
装扮面板
</button>
) : null}
</div>
{showUtilityPanel ? (
<div className="pixel-agents-office-subpanel">
<button type="button" className="pixel-agents-office-btn" onClick={exportLayout}>
导出布局
</button>
<button type="button" className="pixel-agents-office-btn" onClick={() => importInputRef.current?.click()}>
导入布局
</button>
<button
type="button"
className="pixel-agents-office-btn"
onClick={() => {
userZoomRef.current = false;
const frame = frameRef.current;
const layout = officeStateRef.current?.getLayout();
if (!frame || !layout) {
return;
}
const availableWidth = Math.max(240, frame.clientWidth - 8);
const availableHeight = Math.max(240, frame.clientHeight - 8);
const widthZoom = Math.floor(availableWidth / (layout.cols * TILE_SIZE));
const heightZoom = Math.floor(availableHeight / (layout.rows * TILE_SIZE));
setZoom(Math.max(1, Math.min(10, Math.min(widthZoom, heightZoom))));
}}
>
适配宽度
</button>
<button type="button" className="pixel-agents-office-btn" onClick={resetToDefaultLayout}>
恢复默认
</button>
</div>
) : null}
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
void importLayout(file);
event.target.value = "";
}}
/>
<div
ref={frameRef}
className="pixel-agents-office-frame"
onWheelCapture={(event) => {
event.preventDefault();
}}
>
{editor.isEditMode && showEditorPanel ? (
<EditorToolbar
activeTool={editorStateRef.current.activeTool}
selectedTileType={editorStateRef.current.selectedTileType}
selectedFurnitureType={editorStateRef.current.selectedFurnitureType}
selectedFurnitureUid={editorStateRef.current.selectedFurnitureUid}
selectedFurnitureColor={selectedFurnitureColor}
floorColor={editorStateRef.current.floorColor}
wallColor={editorStateRef.current.wallColor}
onToolChange={editor.handleToolChange}
onTileTypeChange={editor.handleTileTypeChange}
onFloorColorChange={editor.handleFloorColorChange}
onWallColorChange={editor.handleWallColorChange}
onSelectedFurnitureColorChange={editor.handleSelectedFurnitureColorChange}
onFurnitureTypeChange={editor.handleFurnitureTypeChange}
loadedAssets={loadedAssets}
/>
) : null}
<VendorOfficeCanvas
officeState={officeStateRef.current}
onClick={(numericId) => {
const actorId = numericIdToActorId.get(numericId);
if (actorId) {
onSelectActor(actorId);
}
}}
isEditMode={editor.isEditMode}
editorState={editorStateRef.current}
onEditorTileAction={editor.handleEditorTileAction}
onEditorEraseAction={editor.handleEditorEraseAction}
onEditorSelectionChange={editor.handleEditorSelectionChange}
onDeleteSelected={editor.handleDeleteSelected}
onRotateSelected={editor.handleRotateSelected}
onDragMove={editor.handleDragMove}
editorTick={editor.editorTick}
zoom={zoom}
onZoomChange={(nextZoom) => {
userZoomRef.current = true;
editor.handleZoomChange(nextZoom);
setZoom(nextZoom);
}}
panRef={panRef}
/>
</div>
</div>
);
}