Files
OpenClaw-bot-review/lib/pixel-office/editor/editorActions.ts
T
xmanrui eebe431560 feat: implement Pixel Office Phase 2 - layout editor, persistence, sound, sub-agent visualization
- Add layout editor with floor/wall paint, erase, furniture place/move/rotate/delete tools
- Add editor state management (undo/redo, selection, drag, ghost preview)
- Add EditorToolbar and EditActionBar UI components
- Add layout persistence API (GET/POST to ~/.openclaw/pixel-office/layout.json)
- Add notification sound system (Web Audio API ascending chime on agent completion)
- Add sub-agent visualization via session JSONL parsing
- Fix agent activity API to read from agents.list config and correct session paths
- Add agent name labels above character sprites in canvas renderer
- Add i18n translations for editor features (zh/en)
2026-02-25 01:39:44 +08:00

210 lines
7.5 KiB
TypeScript

import { TileType, MAX_COLS, MAX_ROWS } from '../types'
import { DEFAULT_NEUTRAL_COLOR } from '../constants'
import type { TileType as TileTypeVal, OfficeLayout, PlacedFurniture, FloorColor } from '../types'
import { getCatalogEntry, getRotatedType, getToggledType } from '../layout/furnitureCatalog'
import { getPlacementBlockedTiles } from '../layout/layoutSerializer'
/** Paint a single tile with pattern and color. Returns new layout (immutable). */
export function paintTile(layout: OfficeLayout, col: number, row: number, tileType: TileTypeVal, color?: FloorColor): OfficeLayout {
const idx = row * layout.cols + col
if (idx < 0 || idx >= layout.tiles.length) return layout
const existingColors = layout.tileColors || new Array(layout.tiles.length).fill(null)
const newColor = color ?? (tileType === TileType.WALL || tileType === TileType.VOID ? null : { ...DEFAULT_NEUTRAL_COLOR })
if (layout.tiles[idx] === tileType) {
const existingColor = existingColors[idx]
if (newColor === null && existingColor === null) return layout
if (newColor && existingColor &&
newColor.h === existingColor.h && newColor.s === existingColor.s &&
newColor.b === existingColor.b && newColor.c === existingColor.c &&
!!newColor.colorize === !!existingColor.colorize) return layout
}
const tiles = [...layout.tiles]
tiles[idx] = tileType
const tileColors = [...existingColors]
tileColors[idx] = newColor
return { ...layout, tiles, tileColors }
}
/** Place furniture. Returns new layout (immutable). */
export function placeFurniture(layout: OfficeLayout, item: PlacedFurniture): OfficeLayout {
if (!canPlaceFurniture(layout, item.type, item.col, item.row)) return layout
return { ...layout, furniture: [...layout.furniture, item] }
}
/** Remove furniture by uid. Returns new layout (immutable). */
export function removeFurniture(layout: OfficeLayout, uid: string): OfficeLayout {
const filtered = layout.furniture.filter((f) => f.uid !== uid)
if (filtered.length === layout.furniture.length) return layout
return { ...layout, furniture: filtered }
}
/** Move furniture to new position. Returns new layout (immutable). */
export function moveFurniture(layout: OfficeLayout, uid: string, newCol: number, newRow: number): OfficeLayout {
const item = layout.furniture.find((f) => f.uid === uid)
if (!item) return layout
if (!canPlaceFurniture(layout, item.type, newCol, newRow, uid)) return layout
return {
...layout,
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, col: newCol, row: newRow } : f)),
}
}
/** Rotate furniture to the next orientation. Returns new layout (immutable). */
export function rotateFurniture(layout: OfficeLayout, uid: string, direction: 'cw' | 'ccw'): OfficeLayout {
const item = layout.furniture.find((f) => f.uid === uid)
if (!item) return layout
const newType = getRotatedType(item.type, direction)
if (!newType) return layout
return {
...layout,
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, type: newType } : f)),
}
}
/** Toggle furniture state (on/off). Returns new layout (immutable). */
export function toggleFurnitureState(layout: OfficeLayout, uid: string): OfficeLayout {
const item = layout.furniture.find((f) => f.uid === uid)
if (!item) return layout
const newType = getToggledType(item.type)
if (!newType) return layout
return {
...layout,
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, type: newType } : f)),
}
}
/** For wall items, offset the row so the bottom row aligns with the hovered tile. */
export function getWallPlacementRow(type: string, row: number): number {
const entry = getCatalogEntry(type)
if (!entry?.canPlaceOnWalls) return row
return row - (entry.footprintH - 1)
}
/** Check if furniture can be placed at (col, row) without overlapping. */
export function canPlaceFurniture(
layout: OfficeLayout,
type: string,
col: number,
row: number,
excludeUid?: string,
): boolean {
const entry = getCatalogEntry(type)
if (!entry) return false
if (entry.canPlaceOnWalls) {
const bottomRow = row + entry.footprintH - 1
if (col < 0 || col + entry.footprintW > layout.cols || bottomRow < 0 || bottomRow >= layout.rows) {
return false
}
} else {
if (col < 0 || row < 0 || col + entry.footprintW > layout.cols || row + entry.footprintH > layout.rows) {
return false
}
}
const bgRows = entry.backgroundTiles || 0
for (let dr = 0; dr < entry.footprintH; dr++) {
if (dr < bgRows) continue
if (row + dr < 0) continue
if (entry.canPlaceOnWalls && dr < entry.footprintH - 1) continue
for (let dc = 0; dc < entry.footprintW; dc++) {
const idx = (row + dr) * layout.cols + (col + dc)
const tileVal = layout.tiles[idx]
if (entry.canPlaceOnWalls) {
if (tileVal !== TileType.WALL) return false
} else {
if (tileVal === TileType.VOID) return false
if (tileVal === TileType.WALL) return false
}
}
}
const occupied = getPlacementBlockedTiles(layout.furniture, excludeUid)
let deskTiles: Set<string> | null = null
if (entry.canPlaceOnSurfaces) {
deskTiles = new Set<string>()
for (const item of layout.furniture) {
if (item.uid === excludeUid) continue
const itemEntry = getCatalogEntry(item.type)
if (!itemEntry || !itemEntry.isDesk) continue
for (let dr = 0; dr < itemEntry.footprintH; dr++) {
for (let dc = 0; dc < itemEntry.footprintW; dc++) {
deskTiles.add(`${item.col + dc},${item.row + dr}`)
}
}
}
}
const newBgRows = entry.backgroundTiles || 0
for (let dr = 0; dr < entry.footprintH; dr++) {
if (dr < newBgRows) continue
if (row + dr < 0) continue
for (let dc = 0; dc < entry.footprintW; dc++) {
const key = `${col + dc},${row + dr}`
if (occupied.has(key) && !(deskTiles?.has(key))) return false
}
}
return true
}
export type ExpandDirection = 'left' | 'right' | 'up' | 'down'
/**
* Expand layout by 1 tile in the given direction. New tiles are VOID.
* Returns { layout, shift } or null if exceeding MAX_COLS/MAX_ROWS.
*/
export function expandLayout(
layout: OfficeLayout,
direction: ExpandDirection,
): { layout: OfficeLayout; shift: { col: number; row: number } } | null {
const { cols, rows, tiles, furniture, tileColors } = layout
const existingColors = tileColors || new Array(tiles.length).fill(null)
let newCols = cols
let newRows = rows
let shiftCol = 0
let shiftRow = 0
if (direction === 'right') {
newCols = cols + 1
} else if (direction === 'left') {
newCols = cols + 1
shiftCol = 1
} else if (direction === 'down') {
newRows = rows + 1
} else if (direction === 'up') {
newRows = rows + 1
shiftRow = 1
}
if (newCols > MAX_COLS || newRows > MAX_ROWS) return null
const newTiles: TileTypeVal[] = new Array(newCols * newRows).fill(TileType.VOID as TileTypeVal)
const newColors: Array<FloorColor | null> = new Array(newCols * newRows).fill(null)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const oldIdx = r * cols + c
const newIdx = (r + shiftRow) * newCols + (c + shiftCol)
newTiles[newIdx] = tiles[oldIdx]
newColors[newIdx] = existingColors[oldIdx]
}
}
const newFurniture: PlacedFurniture[] = furniture.map((f) => ({
...f,
col: f.col + shiftCol,
row: f.row + shiftRow,
}))
return {
layout: { ...layout, cols: newCols, rows: newRows, tiles: newTiles, tileColors: newColors, furniture: newFurniture },
shift: { col: shiftCol, row: shiftRow },
}
}