573 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|